diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 9851f11..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3185 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core 0.5.6", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tokio", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.0", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-test" -version = "14.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167294800740b4b6bc7bfbccbf3a1d50a6c6e097342580ec4c11d1672e456292" -dependencies = [ - "anyhow", - "async-trait", - "auto-future", - "axum 0.7.9", - "bytes", - "cookie", - "http 1.4.0", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower 0.4.13", - "url", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[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 = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[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 = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[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-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[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 = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "engram" -version = "0.1.1" -dependencies = [ - "engram-core", -] - -[[package]] -name = "engram-core" -version = "0.1.1" -dependencies = [ - "anyhow", - "bincode", - "instant-distance", - "rusqlite", - "serde", - "serde_json", - "sled", - "tempfile", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "engram-crypto" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "base64", - "blake3", - "rand", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "engram-ffi" -version = "0.1.0" -dependencies = [ - "engram-core", - "tempfile", - "uuid", -] - -[[package]] -name = "engram-jni" -version = "0.1.0" -dependencies = [ - "engram-core", - "jni", - "serde_json", - "tempfile", - "uuid", -] - -[[package]] -name = "engram-migrate" -version = "0.1.0" -dependencies = [ - "engram-core", -] - -[[package]] -name = "engram-projection" -version = "0.1.0" -dependencies = [ - "base64", - "engram-core", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "engram-reasoning" -version = "0.1.0" -dependencies = [ - "anyhow", - "engram-core", - "serde", - "tempfile", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "engram-server" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum 0.7.9", - "axum-test", - "engram-core", - "engram-crypto", - "engram-projection", - "engram-reasoning", - "engram-sync", - "engram-tx", - "mime_guess", - "rust-embed", - "serde", - "serde_json", - "sled", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tower 0.4.13", - "tower-http 0.5.2", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "engram-sync" -version = "0.1.0" -dependencies = [ - "anyhow", - "engram-core", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "uuid", -] - -[[package]] -name = "engram-tx" -version = "0.1.0" -dependencies = [ - "engram-core", - "serde", - "serde_json", - "sled", - "tempfile", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[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 = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[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 = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.4.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.0", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http 1.4.0", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.4.0", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[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 = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "instant-distance" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c619cdaa30bb84088963968bee12a45ea5fbbf355f2c021bcd15589f5ca494a" -dependencies = [ - "num_cpus", - "ordered-float", - "parking_lot 0.12.5", - "rand", - "rayon", - "serde", - "serde-big-array", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[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 = "libsqlite3-sys" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[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 = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "ordered-float" -version = "3.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" -dependencies = [ - "num-traits", -] - -[[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 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core 0.9.12", -] - -[[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 0.2.16", - "smallvec", - "winapi", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[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 = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[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 = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "h2", - "http 1.4.0", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tower 0.5.3", - "tower-http 0.6.8", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "reserve-port" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rusqlite" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" -dependencies = [ - "bitflags 2.11.1", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "axum 0.8.9", - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http 0.2.12", - "mime", - "mime_guess", - "rand", - "thiserror 1.0.69", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[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-big-array" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" -dependencies = [ - "serde", -] - -[[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 = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[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 0.11.2", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[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 = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[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 = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot 0.12.5", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "iri-string", - "pin-project-lite", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[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 = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[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-futures" -version = "0.4.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[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 = "web-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[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-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[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 = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[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 = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 0422c6e..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "engrams/engram-core", - "engrams/engram-ffi", - "engrams/engram-jni", - "engrams/engram-migrate", - "engrams/engram-sync", - "engrams/engram-server", - "engrams/engram-projection", - "engrams/engram-tx", - "engrams/engram-crypto", - "engrams/engram-reasoning", - # engram-wasm is in receptors/ and compiled separately via wasm-pack - # (wasm targets can't be in the same workspace build as native targets) -] - -# Workspace-level example that depends on engram-core. -# Run with: cargo run --example basic -[[example]] -name = "basic" -path = "examples/basic.rs" - -[[example]] -name = "migrate" -path = "examples/migrate.rs" - -[package] -name = "engram" -version = "0.1.1" -edition = "2021" - -[features] -migration = ["engram-core/migration"] - -[dependencies] -engram-core = { path = "engrams/engram-core" } diff --git a/engram-data-tx-log/conf b/engram-data-tx-log/conf deleted file mode 100644 index 4154d7c..0000000 --- a/engram-data-tx-log/conf +++ /dev/null @@ -1,4 +0,0 @@ -segment_size: 524288 -use_compression: false -version: 0.34 -vQ \ No newline at end of file diff --git a/engram-data-tx-log/db b/engram-data-tx-log/db deleted file mode 100644 index c4d313c..0000000 Binary files a/engram-data-tx-log/db and /dev/null differ diff --git a/engram-data-tx-log/snap.0000000000000060 b/engram-data-tx-log/snap.0000000000000060 deleted file mode 100644 index 444d098..0000000 Binary files a/engram-data-tx-log/snap.0000000000000060 and /dev/null differ diff --git a/engram-data/conf b/engram-data/conf deleted file mode 100644 index 4154d7c..0000000 --- a/engram-data/conf +++ /dev/null @@ -1,4 +0,0 @@ -segment_size: 524288 -use_compression: false -version: 0.34 -vQ \ No newline at end of file diff --git a/engram-data/db b/engram-data/db deleted file mode 100644 index 4b76e0b..0000000 Binary files a/engram-data/db and /dev/null differ diff --git a/engram-data/snap.0000000000035912 b/engram-data/snap.0000000000035912 deleted file mode 100644 index 40126d0..0000000 Binary files a/engram-data/snap.0000000000035912 and /dev/null differ diff --git a/engrams/engram-core/Cargo.toml b/engrams/engram-core/Cargo.toml deleted file mode 100644 index 23fd950..0000000 --- a/engrams/engram-core/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "engram-core" -version = "0.1.1" -edition = "2021" -description = "Engram — native memory substrate for accumulating intelligence" -license = "MIT" - -[features] -default = ["sled-backend"] -sled-backend = ["dep:sled"] -wasm = [] -migration = ["dep:rusqlite"] - -[dependencies] -sled = { version = "0.34", optional = true } -uuid = { version = "1", features = ["v4", "serde"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -bincode = "1" -anyhow = "1" -thiserror = "1" -instant-distance = { version = "0.6", features = ["with-serde"] } -rusqlite = { version = "0.31", optional = true } - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-core/src/activation.rs b/engrams/engram-core/src/activation.rs deleted file mode 100644 index 4da5c21..0000000 --- a/engrams/engram-core/src/activation.rs +++ /dev/null @@ -1,318 +0,0 @@ -/// 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::types::{ActivatedNode, Node}; -use crate::vector::cosine_similarity; -use std::collections::{BinaryHeap, HashMap}; -use uuid::Uuid; - -#[cfg(feature = "sled-backend")] -use crate::graph; -#[cfg(feature = "sled-backend")] -use sled::Db; - -#[cfg(feature = "wasm")] -use crate::mem_storage::MemStore; - -/// 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). -#[cfg(feature = "sled-backend")] -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) -} - -/// In-memory spreading activation for the WASM backend. -/// -/// Identical algorithm to `activate` but reads from a `MemStore` instead of sled. -#[cfg(feature = "wasm")] -pub fn activate_mem( - store: &MemStore, - seeds: &[Uuid], - query_embedding: &[f32], - max_depth: u8, - limit: usize, -) -> EngramResult> { - let mut best_strength: HashMap = HashMap::new(); - let mut queue: BinaryHeap = BinaryHeap::new(); - - for &seed in seeds { - queue.push(Candidate { strength: 1.0, hops: 0, id: seed }); - best_strength.insert(seed, (1.0, 0)); - } - - while let Some(Candidate { strength, hops, id }) = queue.pop() { - if hops >= max_depth { - continue; - } - let edges = store.read_edges_from(id)?; - for edge in &edges { - let target_id = edge.to_id; - let target: Node = match store.read_node(target_id)? { - Some(n) => n, - None => continue, - }; - let semantic_sim = cosine_similarity(query_embedding, &target.embedding).max(0.0); - let new_strength = strength * edge.weight * target.salience.max(0.0) * semantic_sim; - if new_strength < PRUNE_THRESHOLD { - continue; - } - let next_hops = hops + 1; - 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 }); - } - } - } - - 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) = store.read_node(*id)? { - results.push(ActivatedNode { - node, - activation_strength: *strength, - hops: *hops, - }); - } - } - - 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/engrams/engram-core/src/consolidation.rs b/engrams/engram-core/src/consolidation.rs deleted file mode 100644 index 8ab02a5..0000000 --- a/engrams/engram-core/src/consolidation.rs +++ /dev/null @@ -1,308 +0,0 @@ -/// Consolidation — promoting Episodic memories to Semantic knowledge. -/// -/// Biological memory consolidation is the process by which unstable, -/// hippocampus-dependent memories are gradually transformed into stable, -/// neocortex-integrated semantic knowledge. In the brain this happens -/// primarily during sleep through hippocampal replay. -/// -/// Here, consolidation is explicit and on-demand. The caller decides when to -/// run a consolidation cycle and with what thresholds. The engine: -/// -/// 1. Scans all Episodic nodes. -/// 2. Promotes those that have been activated enough (high activation_count) -/// and are still salient enough (above salience_floor) to MemoryTier::Semantic. -/// 3. Runs a global salience decay pass to age all nodes. -/// 4. Returns a report of what changed. -/// -/// This models the idea that memories become "knowledge" not by being told they -/// should be, but by being *used* — activated, reinforced, and found relevant -/// repeatedly over time. -use crate::error::EngramResult; -use crate::salience; -use crate::types::{MemoryTier, Node}; - -#[cfg(feature = "sled-backend")] -use crate::storage; -#[cfg(feature = "sled-backend")] -use crate::graph; -#[cfg(feature = "sled-backend")] -use sled::Db; - -#[cfg(feature = "wasm")] -use crate::mem_storage::MemStore; - -/// Configuration for a consolidation run. -#[derive(Debug, Clone)] -pub struct ConsolidationConfig { - /// Episodic nodes with activation_count >= this threshold are candidates for promotion. - pub episodic_to_semantic_threshold: u64, - /// Candidates must also have salience >= this floor to be promoted. - pub salience_floor: f32, - /// Maximum number of promotions per consolidation cycle (prevents runaway batch writes). - pub max_promotions_per_run: usize, - /// Decay factor applied to all node saliences after promotion (0.0–1.0). - pub decay_factor: f32, -} - -impl Default for ConsolidationConfig { - fn default() -> Self { - Self { - episodic_to_semantic_threshold: 5, - salience_floor: 0.3, - max_promotions_per_run: 50, - decay_factor: 0.98, - } - } -} - -/// Summary of what happened during a consolidation cycle. -#[derive(Debug, Default, Clone)] -pub struct ConsolidationReport { - /// Number of Episodic nodes promoted to Semantic. - pub promoted: usize, - /// Number of nodes whose salience was updated by the decay pass. - pub decayed: usize, - /// Number of nodes removed because their salience dropped below the minimum - /// (currently unused — pruning is opt-in in v0.1). - pub pruned: usize, -} - -// ── sled-backed consolidation ───────────────────────────────────────────────── - -#[cfg(feature = "sled-backend")] -/// Run a consolidation cycle against the open sled database. -pub fn consolidate(db: &Db, config: &ConsolidationConfig) -> EngramResult { - let mut report = ConsolidationReport::default(); - - // Step 1: scan all nodes, identify Episodic candidates. - let all_nodes: Vec = storage::scan_nodes(db)?; - - let mut promoted_count = 0usize; - - for mut node in all_nodes { - if node.tier != MemoryTier::Episodic { - continue; - } - - if node.activation_count >= config.episodic_to_semantic_threshold - && node.salience >= config.salience_floor - { - // Promote: change tier to Semantic and persist. - // Use overwrite_node — this is an internal state update, not a new node. - node.tier = MemoryTier::Semantic; - graph::overwrite_node(db, &node)?; - promoted_count += 1; - - if promoted_count >= config.max_promotions_per_run { - break; - } - } - } - - report.promoted = promoted_count; - - // Step 2: global salience decay. - let all_nodes_post: Vec = storage::scan_nodes(db)?; - let mut decayed_count = 0usize; - for mut node in all_nodes_post { - let new_sal = salience::decay_salience(node.salience, config.decay_factor); - if new_sal != node.salience { - node.salience = new_sal; - storage::write_salience(db, node.id, new_sal)?; - graph::overwrite_node(db, &node)?; - decayed_count += 1; - } - } - - report.decayed = decayed_count; - Ok(report) -} - -// ── in-memory consolidation (wasm) ──────────────────────────────────────────── - -#[cfg(feature = "wasm")] -/// Run a consolidation cycle against the in-memory store. -pub fn consolidate_mem( - store: &mut MemStore, - config: &ConsolidationConfig, -) -> EngramResult { - let mut report = ConsolidationReport::default(); - let mut promoted_count = 0usize; - - let all_ids: Vec = store.nodes.keys().copied().collect(); - - for id in &all_ids { - if promoted_count >= config.max_promotions_per_run { - break; - } - if let Some(node) = store.nodes.get_mut(id) { - if node.tier == MemoryTier::Episodic - && node.activation_count >= config.episodic_to_semantic_threshold - && node.salience >= config.salience_floor - { - node.tier = MemoryTier::Semantic; - promoted_count += 1; - } - } - } - - report.promoted = promoted_count; - - let mut decayed_count = 0usize; - for node in store.nodes.values_mut() { - let new_sal = salience::decay_salience(node.salience, config.decay_factor); - if new_sal != node.salience { - node.salience = new_sal; - decayed_count += 1; - } - } - - report.decayed = decayed_count; - Ok(report) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{MemoryTier, Node, NodeType}; - - fn episodic_node_with_activations(count: u64, salience: f32) -> Node { - let mut node = Node::new( - NodeType::Memory, - vec![0.5; 4], - b"test memory".to_vec(), - MemoryTier::Episodic, - 0.8, - ); - node.activation_count = count; - node.salience = salience; - node - } - - #[test] - fn default_config_sensible_values() { - let cfg = ConsolidationConfig::default(); - assert_eq!(cfg.episodic_to_semantic_threshold, 5); - assert!(cfg.salience_floor > 0.0); - assert!(cfg.max_promotions_per_run > 0); - assert!(cfg.decay_factor > 0.0 && cfg.decay_factor <= 1.0); - } - - #[test] - fn node_is_promotion_candidate() { - let cfg = ConsolidationConfig::default(); - let node = episodic_node_with_activations(10, 0.8); - let is_candidate = node.tier == MemoryTier::Episodic - && node.activation_count >= cfg.episodic_to_semantic_threshold - && node.salience >= cfg.salience_floor; - assert!(is_candidate); - } - - #[test] - fn node_below_threshold_not_candidate() { - let cfg = ConsolidationConfig::default(); - // activation_count below threshold - let node = episodic_node_with_activations(2, 0.8); - let is_candidate = node.tier == MemoryTier::Episodic - && node.activation_count >= cfg.episodic_to_semantic_threshold - && node.salience >= cfg.salience_floor; - assert!(!is_candidate); - } - - #[test] - fn node_below_salience_floor_not_candidate() { - let cfg = ConsolidationConfig::default(); - // salience below floor - let node = episodic_node_with_activations(10, 0.1); - let is_candidate = node.tier == MemoryTier::Episodic - && node.activation_count >= cfg.episodic_to_semantic_threshold - && node.salience >= cfg.salience_floor; - assert!(!is_candidate); - } - - #[test] - fn decay_reduces_salience() { - let original = 1.0f32; - let decayed = salience::decay_salience(original, 0.98); - assert!(decayed < original); - assert!((decayed - 0.98).abs() < 1e-6); - } - - #[test] - fn report_default_is_zero() { - let r = ConsolidationReport::default(); - assert_eq!(r.promoted, 0); - assert_eq!(r.decayed, 0); - assert_eq!(r.pruned, 0); - } - - #[cfg(feature = "sled-backend")] - #[test] - fn consolidate_promotes_eligible_episodic_nodes() { - use crate::graph; - - let dir = tempfile::tempdir().unwrap(); - let sled_db = sled::open(dir.path()).unwrap(); - - let node = episodic_node_with_activations(10, 0.8); - graph::put_node(&sled_db, &node).unwrap(); - - let cfg = ConsolidationConfig::default(); - let report = consolidate(&sled_db, &cfg).unwrap(); - - assert_eq!(report.promoted, 1); - - let stored = graph::get_node(&sled_db, node.id).unwrap().unwrap(); - assert_eq!(stored.tier, MemoryTier::Semantic); - } - - #[cfg(feature = "sled-backend")] - #[test] - fn consolidate_respects_max_promotions() { - use crate::graph; - - let dir = tempfile::tempdir().unwrap(); - let sled_db = sled::open(dir.path()).unwrap(); - - // Insert 10 eligible nodes. - for _ in 0..10 { - let node = episodic_node_with_activations(20, 0.9); - graph::put_node(&sled_db, &node).unwrap(); - } - - let cfg = ConsolidationConfig { - max_promotions_per_run: 3, - ..Default::default() - }; - let report = consolidate(&sled_db, &cfg).unwrap(); - assert_eq!(report.promoted, 3); - } - - #[cfg(feature = "sled-backend")] - #[test] - fn consolidate_runs_decay_after_promotion() { - use crate::graph; - - let dir = tempfile::tempdir().unwrap(); - let sled_db = sled::open(dir.path()).unwrap(); - - let node = Node::new( - NodeType::Concept, - vec![0.0; 4], - b"semantic".to_vec(), - MemoryTier::Semantic, - 0.5, - ); - let original_salience = node.salience; - graph::put_node(&sled_db, &node).unwrap(); - - let cfg = ConsolidationConfig::default(); - let report = consolidate(&sled_db, &cfg).unwrap(); - - assert!(report.decayed >= 1); - let stored = graph::get_node(&sled_db, node.id).unwrap().unwrap(); - assert!(stored.salience < original_salience); - } -} diff --git a/engrams/engram-core/src/db.rs b/engrams/engram-core/src/db.rs deleted file mode 100644 index c71abd6..0000000 --- a/engrams/engram-core/src/db.rs +++ /dev/null @@ -1,549 +0,0 @@ -/// EngramDb — the top-level database handle. -/// -/// All public API methods live here. The internal modules (graph, vector, -/// activation, salience, consolidation) are implementation details. Callers -/// interact only with EngramDb. -/// -/// # Feature flags -/// - `sled-backend` (default): persistent storage via sled -/// - `wasm`: in-memory storage only (no filesystem), for WASM targets - -// ── sled-backed implementation ──────────────────────────────────────────────── - -#[cfg(feature = "sled-backend")] -mod sled_impl { - use crate::activation; - use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport}; - use crate::edge_type; - use crate::error::{EngramError, EngramResult}; - use crate::graph; - use crate::salience; - use crate::storage; - use crate::types::{ActivatedNode, Edge, EdgeTypeDef, Node, ScoredNode, now_ms}; - use crate::vector; - use sled::Db; - use std::path::Path; - use uuid::Uuid; - - pub struct EngramDb { - pub(crate) db: Db, - } - - impl Clone for EngramDb { - /// Clone shares the same underlying sled instance (single file lock, - /// multiple in-process handles). This is safe and avoids re-opening. - fn clone(&self) -> Self { - Self { db: self.db.clone() } - } - } - - impl EngramDb { - /// Open (or create) an engram database at the given path. - /// - /// Seeds all built-in edge types on first open. Idempotent — existing - /// definitions are not overwritten on subsequent opens. - pub fn open(path: &Path) -> EngramResult { - let db = sled::open(path)?; - edge_type::seed_builtin_types(&db)?; - Ok(Self { db }) - } - - // ── Node operations ─────────────────────────────────────────────────── - - /// Persist a new node. Returns the node's UUID. - /// - /// Returns `EngramError::NodeAlreadyExists` if the ID already exists. - /// Nodes are immutable — to update, create a new node and add a - /// `supersedes` edge from new → old. - pub fn put_node(&self, node: Node) -> EngramResult { - let id = graph::put_node(&self.db, &node)?; - // Mark HNSW index dirty so next search rebuilds it. - vector::mark_dirty(&self.db); - Ok(id) - } - - /// 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`. - /// - /// Falls back to flat scan for stores with < 100 nodes. - /// Uses the HNSW index for larger stores. - pub fn search_embedding( - &self, - embedding: &[f32], - limit: usize, - ) -> EngramResult> { - vector::search_embedding(&self.db, embedding, limit, |id| { - graph::get_node(&self.db, id) - }) - } - - /// Explicitly build (or rebuild) the HNSW index. - /// - /// This is not normally needed — the index is built lazily on first search. - /// Call this if you want to pre-warm the index after a large batch insert. - /// - /// Returns the number of nodes indexed. - pub fn build_index(&self) -> EngramResult { - vector::build_index(&self.db) - } - - // ── Spreading activation ────────────────────────────────────────────── - - /// Run spreading activation from a set of seed nodes. - 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 `Some("causes")`, only edges with that relation - /// name are followed. Pass `None` to follow all edges. - pub fn traverse( - &self, - from: Uuid, - relation: Option<&str>, - max_depth: u8, - ) -> EngramResult> { - graph::traverse(&self.db, from, relation, max_depth) - } - - // ── Edge type registry (native bindings for el) ─────────────────────── - // - // These are the el-callable surfaces. All intelligence about when to - // create types, how to score confidence, and when to merge/split lives - // in el. Rust stores and retrieves. - - /// Create or update an edge type. If the type already exists its - /// description and confidence are updated; id, first_observed, - /// instance_count, derived_from, supersedes, and deprecated are preserved. - pub fn native_edge_type_put( - &self, - name: &str, - description: &str, - confidence: f32, - ) -> EngramResult<()> { - let confidence = confidence.clamp(0.0, 1.0); - if let Some(mut existing) = edge_type::get_edge_type(&self.db, name)? { - existing.description = description.to_string(); - existing.confidence = confidence; - edge_type::register_edge_type(&self.db, &existing)?; - } else { - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: name.to_string(), - description: description.to_string(), - first_observed: now_ms(), - instance_count: 0, - confidence, - derived_from: None, - supersedes: None, - deprecated: false, - }; - edge_type::register_edge_type(&self.db, &def)?; - } - Ok(()) - } - - /// Retrieve an edge type as a JSON string. Returns empty string if not found. - pub fn native_edge_type_get(&self, name: &str) -> EngramResult { - match edge_type::get_edge_type(&self.db, name)? { - Some(def) => Ok(serde_json::to_string(&def).unwrap_or_default()), - None => Ok(String::new()), - } - } - - /// List all registered edge types as a JSON array string. - pub fn native_edge_type_list(&self) -> EngramResult { - let defs = edge_type::all_edge_types(&self.db)?; - Ok(serde_json::to_string(&defs).unwrap_or_default()) - } - - /// Increment the instance_count for a named edge type by one. - pub fn native_edge_type_increment_count(&self, name: &str) -> EngramResult<()> { - edge_type::increment_edge_type_count(&self.db, name) - } - - /// Update the description and confidence of an existing edge type. - /// instance_count, id, first_observed, and other metadata are preserved. - pub fn native_edge_type_update( - &self, - name: &str, - description: &str, - confidence: f32, - ) -> EngramResult<()> { - if let Some(mut def) = edge_type::get_edge_type(&self.db, name)? { - def.description = description.to_string(); - def.confidence = confidence.clamp(0.0, 1.0); - edge_type::register_edge_type(&self.db, &def)?; - } - Ok(()) - } - - // ── Salience management ─────────────────────────────────────────────── - - /// Mark a node as recently activated — update last_activated, increment - /// activation_count, and recompute salience. - 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, - ); - // Use overwrite_node — touch is an internal state update, not a new node. - graph::overwrite_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). Returns the number of nodes updated. - 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); - if new_salience != node.salience { - node.salience = new_salience; - storage::write_salience(&self.db, node.id, new_salience)?; - graph::overwrite_node(&self.db, &node)?; - count += 1; - } - } - Ok(count) - } - - // ── Consolidation ───────────────────────────────────────────────────── - - /// Run a memory consolidation cycle. - /// - /// Promotes Episodic nodes that have been activated enough times and are - /// still salient enough to MemoryTier::Semantic. Then decays all saliences. - /// - /// See `consolidation::ConsolidationConfig` for tuning knobs. - pub fn consolidate( - &self, - config: &ConsolidationConfig, - ) -> EngramResult { - consolidation::consolidate(&self.db, config) - } - - // ── Statistics ──────────────────────────────────────────────────────── - - /// Total number of nodes stored. - pub fn node_count(&self) -> EngramResult { - graph::node_count(&self.db) - } - - /// Total number of edges stored. - pub fn edge_count(&self) -> EngramResult { - graph::edge_count(&self.db) - } - - // ── Bulk scan (for sync) ─────────────────────────────────────────────── - - /// Scan all nodes in the store. - /// - /// Used by the sync engine to generate delta snapshots. - pub fn scan_nodes(&self) -> EngramResult> { - storage::scan_nodes(&self.db) - } - - /// Scan all edges in the store (forward index only). - pub fn scan_edges(&self) -> EngramResult> { - let prefix = b"edges:from:"; - let mut edges = Vec::new(); - for result in self.db.scan_prefix(prefix) { - let (_k, v) = result?; - let edge: Edge = bincode::deserialize(&v)?; - edges.push(edge); - } - Ok(edges) - } - - /// Delete a node by UUID (tombstone support for sync). - pub fn delete_node(&self, id: Uuid) -> EngramResult<()> { - let key = storage::node_key(id); - self.db.remove(key)?; - Ok(()) - } - } -} - -// ── WASM / in-memory implementation ────────────────────────────────────────── - -#[cfg(feature = "wasm")] -mod wasm_impl { - use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport}; - use crate::error::{EngramError, EngramResult}; - use crate::mem_storage::MemStore; - use crate::salience; - use crate::types::{ActivatedNode, Edge, Node, ScoredNode}; - use crate::vector; - use std::collections::{HashMap, HashSet, VecDeque}; - use std::sync::RwLock; - use uuid::Uuid; - - pub struct EngramDb { - pub(crate) store: RwLock, - } - - impl EngramDb { - /// Create an in-memory engram database. The `path` argument is ignored in WASM mode. - pub fn open(_path: &std::path::Path) -> EngramResult { - Ok(Self { - store: RwLock::new(MemStore::new()), - }) - } - - // ── Node operations ─────────────────────────────────────────────────── - - pub fn put_node(&self, node: Node) -> EngramResult { - let id = node.id; - self.store - .write() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .write_node(&node)?; - Ok(id) - } - - pub fn get_node(&self, id: Uuid) -> EngramResult> { - self.store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .read_node(id) - } - - // ── Edge operations ─────────────────────────────────────────────────── - - pub fn put_edge(&self, edge: Edge) -> EngramResult<()> { - self.store - .write() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .write_edge(&edge) - } - - pub fn get_edges_from(&self, from_id: Uuid) -> EngramResult> { - self.store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .read_edges_from(from_id) - } - - pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult> { - self.store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .read_edges_to(to_id) - } - - // ── Vector search ───────────────────────────────────────────────────── - - pub fn search_embedding( - &self, - embedding: &[f32], - limit: usize, - ) -> EngramResult> { - let store = self - .store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - let vectors = store.scan_vectors()?; - let nodes_snap: HashMap = store.nodes.clone(); - drop(store); - - vector::search_embedding_memory(embedding, limit, &vectors, |id| { - Ok(nodes_snap.get(&id).cloned()) - }) - } - - /// No-op in WASM mode (flat scan is always used). Returns node count. - pub fn build_index(&self) -> EngramResult { - self.node_count() - } - - // ── Spreading activation ────────────────────────────────────────────── - - pub fn activate( - &self, - seeds: &[Uuid], - query_embedding: &[f32], - max_depth: u8, - limit: usize, - ) -> EngramResult> { - use crate::activation; - let store = self - .store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - activation::activate_mem(&store, seeds, query_embedding, max_depth, limit) - } - - // ── Graph traversal ─────────────────────────────────────────────────── - - pub fn traverse( - &self, - from: Uuid, - relation: Option<&str>, - max_depth: u8, - ) -> EngramResult> { - let store = self - .store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - 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 = store.read_edges_from(current_id)?; - for edge in edges { - if let Some(rel) = relation { - if edge.relation != rel { - continue; - } - } - let next = edge.to_id; - if visited.contains(&next) { - continue; - } - visited.insert(next); - if let Some(node) = store.read_node(next)? { - result.push(node); - queue.push_back((next, depth + 1)); - } - } - } - Ok(result) - } - - // ── Salience management ─────────────────────────────────────────────── - - pub fn touch(&self, id: Uuid) -> EngramResult<()> { - let mut store = self - .store - .write() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - let node = store - .nodes - .get_mut(&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, - ); - Ok(()) - } - - 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 mut store = self - .store - .write() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - let mut count = 0usize; - for node in store.nodes.values_mut() { - let new_sal = salience::decay_salience(node.salience, factor); - if new_sal != node.salience { - node.salience = new_sal; - count += 1; - } - } - Ok(count) - } - - // ── Consolidation ───────────────────────────────────────────────────── - - pub fn consolidate( - &self, - config: &ConsolidationConfig, - ) -> EngramResult { - let mut store = self - .store - .write() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?; - consolidation::consolidate_mem(&mut store, config) - } - - // ── Statistics ──────────────────────────────────────────────────────── - - pub fn node_count(&self) -> EngramResult { - Ok(self - .store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .node_count()) - } - - pub fn edge_count(&self) -> EngramResult { - Ok(self - .store - .read() - .map_err(|_| EngramError::InvalidParam("lock poisoned".into()))? - .edge_count()) - } - } -} - -// ── Re-export the right impl ────────────────────────────────────────────────── - -#[cfg(feature = "sled-backend")] -pub use sled_impl::EngramDb; - -#[cfg(feature = "wasm")] -pub use wasm_impl::EngramDb; diff --git a/engrams/engram-core/src/edge_type.rs b/engrams/engram-core/src/edge_type.rs deleted file mode 100644 index 81c9819..0000000 --- a/engrams/engram-core/src/edge_type.rs +++ /dev/null @@ -1,443 +0,0 @@ -/// Edge type registry — dynamic, first-class edge type management. -/// -/// Edge types are stored under the key prefix `edge_types:` in the sled -/// database. Each value is a bincode-encoded `EdgeTypeDef`. -/// -/// # Operations -/// -/// - `register_edge_type` — create or update a type definition -/// - `get_edge_type` — look up by name -/// - `update_edge_type_description` — change the human-readable description -/// - `increment_edge_type_count` — bump the instance counter when a new edge is created -/// - `merge_edge_types` — retag all edges from one type to another and deprecate the source -/// - `split_edge_type` — record a split operation; the predicate is stored as a description note -/// - `deprecate_edge_type` — mark a type as no longer current -/// - `all_edge_types` — return every registered type -/// - `edge_types_by_confidence` — filter by minimum confidence score - -use crate::error::EngramResult; -use crate::storage; -use crate::types::{now_ms, Edge, EdgeTypeDef}; -use sled::Db; -use uuid::Uuid; - -// ── Key helpers ─────────────────────────────────────────────────────────────── - -fn edge_type_key(name: &str) -> Vec { - format!("edge_types:{}", name).into_bytes() -} - -const EDGE_TYPE_PREFIX: &[u8] = b"edge_types:"; - -// ── Public API ──────────────────────────────────────────────────────────────── - -/// Register a new edge type, or overwrite an existing definition. -/// -/// Returns the UUID of the stored `EdgeTypeDef`. -pub fn register_edge_type(db: &Db, def: &EdgeTypeDef) -> EngramResult { - let key = edge_type_key(&def.name); - let val = bincode::serialize(def)?; - db.insert(key, val)?; - Ok(def.id) -} - -/// Look up an edge type by its canonical name. -pub fn get_edge_type(db: &Db, name: &str) -> EngramResult> { - match db.get(edge_type_key(name))? { - Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)), - None => Ok(None), - } -} - -/// Update the human-readable description of an existing edge type. -/// -/// No-ops silently if the type does not exist. -pub fn update_edge_type_description(db: &Db, name: &str, description: &str) -> EngramResult<()> { - if let Some(mut def) = get_edge_type(db, name)? { - def.description = description.to_string(); - let val = bincode::serialize(&def)?; - db.insert(edge_type_key(name), val)?; - } - Ok(()) -} - -/// Increment the instance counter for an edge type. -/// -/// Called whenever a new edge with this type is persisted. No-ops if the type -/// is not registered (the counter stays in-registry, not in the edge itself). -pub fn increment_edge_type_count(db: &Db, name: &str) -> EngramResult<()> { - if let Some(mut def) = get_edge_type(db, name)? { - def.instance_count = def.instance_count.saturating_add(1); - let val = bincode::serialize(&def)?; - db.insert(edge_type_key(name), val)?; - } - Ok(()) -} - -/// Merge two edge types: retag all edges from `from_name` to `into_name`, -/// then deprecate `from_name`. -/// -/// After this call every edge that carried `from_name` will carry `into_name` -/// instead. The `from_name` definition is marked deprecated and its `supersedes` -/// field records the merge destination. -pub fn merge_edge_types(db: &Db, from_name: &str, into_name: &str) -> EngramResult<()> { - // Collect and retag every edge carrying from_name - let prefix = b"edges:from:"; - let mut edges_to_retag: Vec = Vec::new(); - for result in db.scan_prefix(prefix) { - let (_k, v) = result?; - let edge: Edge = bincode::deserialize(&v)?; - if edge.relation == from_name { - edges_to_retag.push(edge); - } - } - - for mut edge in edges_to_retag { - edge.relation = into_name.to_string(); - storage::write_edge(db, &edge)?; - } - - // Deprecate the source type and record the merge destination - if let Some(mut def) = get_edge_type(db, from_name)? { - def.deprecated = true; - def.supersedes = Some(into_name.to_string()); - let val = bincode::serialize(&def)?; - db.insert(edge_type_key(from_name), val)?; - } - - // Update instance count on the destination to account for the absorbed edges - if let Some(mut into_def) = get_edge_type(db, into_name)? { - // Recount from graph (simple: scan all edges for into_name) - let mut count = 0u64; - for result in db.scan_prefix(prefix) { - let (_k, v) = result?; - let edge: Edge = bincode::deserialize(&v)?; - if edge.relation == into_name { - count += 1; - } - } - into_def.instance_count = count; - let val = bincode::serialize(&into_def)?; - db.insert(edge_type_key(into_name), val)?; - } - - Ok(()) -} - -/// Record a split of `name` into `new_name_a` and `new_name_b`. -/// -/// The predicate that drives the split is stored as a descriptive note on -/// both new types. This does NOT automatically retag edges — the caller is -/// responsible for deciding which edges go to `new_name_a` vs `new_name_b` -/// and calling `storage::write_edge` for each. The split records the intent; -/// the actual retagging is domain-specific. -/// -/// The original type is deprecated with a note referencing the two successors. -pub fn split_edge_type( - db: &Db, - name: &str, - new_name_a: &str, - new_name_b: &str, - predicate: &str, -) -> EngramResult<()> { - let now = now_ms(); - - // Deprecate the original - if let Some(mut original) = get_edge_type(db, name)? { - original.deprecated = true; - original.description = format!( - "{} [SPLIT into '{}' and '{}' via predicate: {}]", - original.description, new_name_a, new_name_b, predicate - ); - let val = bincode::serialize(&original)?; - db.insert(edge_type_key(name), val)?; - } - - // Register new_name_a if it doesn't already exist - if get_edge_type(db, new_name_a)?.is_none() { - let def_a = EdgeTypeDef { - id: Uuid::new_v4(), - name: new_name_a.to_string(), - description: format!( - "Split from '{}' — predicate: {}", - name, predicate - ), - first_observed: now, - instance_count: 0, - confidence: 0.0, - derived_from: Some(format!("split from '{}'", name)), - supersedes: None, - deprecated: false, - }; - register_edge_type(db, &def_a)?; - } - - // Register new_name_b if it doesn't already exist - if get_edge_type(db, new_name_b)?.is_none() { - let def_b = EdgeTypeDef { - id: Uuid::new_v4(), - name: new_name_b.to_string(), - description: format!( - "Split from '{}' — predicate: {}", - name, predicate - ), - first_observed: now, - instance_count: 0, - confidence: 0.0, - derived_from: Some(format!("split from '{}'", name)), - supersedes: None, - deprecated: false, - }; - register_edge_type(db, &def_b)?; - } - - Ok(()) -} - -/// Mark an edge type as deprecated. Deprecated types should not be used on -/// new edges, but existing edges carrying this type remain valid. -pub fn deprecate_edge_type(db: &Db, name: &str) -> EngramResult<()> { - if let Some(mut def) = get_edge_type(db, name)? { - def.deprecated = true; - let val = bincode::serialize(&def)?; - db.insert(edge_type_key(name), val)?; - } - Ok(()) -} - -/// Return all registered edge type definitions. -pub fn all_edge_types(db: &Db) -> EngramResult> { - let mut types = Vec::new(); - for result in db.scan_prefix(EDGE_TYPE_PREFIX) { - let (_k, v) = result?; - let def: EdgeTypeDef = bincode::deserialize(&v)?; - types.push(def); - } - Ok(types) -} - -/// Return all edge types with a confidence score at or above `min_confidence`. -pub fn edge_types_by_confidence(db: &Db, min_confidence: f32) -> EngramResult> { - let mut types = all_edge_types(db)?; - types.retain(|t| t.confidence >= min_confidence); - types.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal)); - Ok(types) -} - -// ── Built-in type seed ──────────────────────────────────────────────────────── - -/// Register all built-in edge types if they have not already been registered. -/// -/// Called once from `EngramDb::open`. Idempotent — existing definitions are -/// not overwritten, so user customisations survive restarts. -pub fn seed_builtin_types(db: &Db) -> EngramResult<()> { - let now = now_ms(); - - // Original relational types — confidence 1.0 (canonical, well-established) - let originals: &[(&str, &str)] = &[ - ("supersedes", "This node replaces or obsoletes another"), - ("causes", "This node is a causal precursor to another"), - ("contains", "This node hierarchically contains another"), - ("references", "This node cites another as supporting context"), - ("contradicts", "This node is in logical tension with another"), - ("exemplifies", "This node is a concrete instance of a more abstract node"), - ("activates", "Co-activation: firing this tends to fire the other"), - ("temporally_precedes", "Temporal ordering: this node came before the other"), - ]; - - for (name, description) in originals { - if get_edge_type(db, name)?.is_none() { - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: name.to_string(), - description: description.to_string(), - first_observed: now, - instance_count: 0, - confidence: 1.0, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(db, &def)?; - } - } - - // Personhood / relational types — confidence 0.9 (well-understood but newer) - let personhood: &[(&str, &str)] = &[ - ("grounded_in", "This value or belief is rooted in this experience"), - ("reinforced_by", "This pattern kept being confirmed by this"), - ("derives_from", "This preference or belief flows from this value"), - ("in_tension_with", "These two things pull against each other"), - ("expressed_through","This value surfaces in this voice or behavior"), - ("shaped_by", "This pattern was formed by this relationship or experience"), - ("challenged_by", "This belief was tested by this experience"), - ("resonates_with", "This memory echoes this value"), - ]; - - for (name, description) in personhood { - if get_edge_type(db, name)?.is_none() { - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: name.to_string(), - description: description.to_string(), - first_observed: now, - instance_count: 0, - confidence: 0.9, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(db, &def)?; - } - } - - Ok(()) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn open_tmp() -> (Db, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let db = sled::open(dir.path()).unwrap(); - (db, dir) - } - - #[test] - fn register_and_retrieve() { - let (db, _dir) = open_tmp(); - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: "causes".to_string(), - description: "A causes B".to_string(), - first_observed: 0, - instance_count: 0, - confidence: 1.0, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(&db, &def).unwrap(); - let got = get_edge_type(&db, "causes").unwrap().unwrap(); - assert_eq!(got.name, "causes"); - assert!((got.confidence - 1.0).abs() < f32::EPSILON); - } - - #[test] - fn update_description() { - let (db, _dir) = open_tmp(); - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: "test_type".to_string(), - description: "old".to_string(), - first_observed: 0, - instance_count: 0, - confidence: 0.5, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(&db, &def).unwrap(); - update_edge_type_description(&db, "test_type", "new description").unwrap(); - let got = get_edge_type(&db, "test_type").unwrap().unwrap(); - assert_eq!(got.description, "new description"); - } - - #[test] - fn increment_count() { - let (db, _dir) = open_tmp(); - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: "references".to_string(), - description: "refs".to_string(), - first_observed: 0, - instance_count: 5, - confidence: 1.0, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(&db, &def).unwrap(); - increment_edge_type_count(&db, "references").unwrap(); - let got = get_edge_type(&db, "references").unwrap().unwrap(); - assert_eq!(got.instance_count, 6); - } - - #[test] - fn deprecate() { - let (db, _dir) = open_tmp(); - let def = EdgeTypeDef { - id: Uuid::new_v4(), - name: "old_type".to_string(), - description: "going away".to_string(), - first_observed: 0, - instance_count: 0, - confidence: 0.3, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(&db, &def).unwrap(); - deprecate_edge_type(&db, "old_type").unwrap(); - let got = get_edge_type(&db, "old_type").unwrap().unwrap(); - assert!(got.deprecated); - } - - #[test] - fn all_types_and_confidence_filter() { - let (db, _dir) = open_tmp(); - seed_builtin_types(&db).unwrap(); - - let all = all_edge_types(&db).unwrap(); - assert!(all.len() >= 16); // 8 originals + 8 personhood - - // All original types have confidence 1.0 - let high = edge_types_by_confidence(&db, 1.0).unwrap(); - assert!(high.len() >= 8); - for t in &high { - assert!((t.confidence - 1.0).abs() < f32::EPSILON); - } - - // personhood types have confidence 0.9 — included when threshold is <= 0.9 - let wide = edge_types_by_confidence(&db, 0.9).unwrap(); - assert!(wide.len() >= 16); - } - - #[test] - fn seed_is_idempotent() { - let (db, _dir) = open_tmp(); - seed_builtin_types(&db).unwrap(); - seed_builtin_types(&db).unwrap(); // second call must not panic or duplicate - let all = all_edge_types(&db).unwrap(); - // All names should be distinct - let mut names: Vec = all.iter().map(|t| t.name.clone()).collect(); - names.sort(); - names.dedup(); - assert_eq!(names.len(), all.len()); - } - - #[test] - fn split_type_records_both_halves() { - let (db, _dir) = open_tmp(); - let original = EdgeTypeDef { - id: Uuid::new_v4(), - name: "relates_to".to_string(), - description: "generic relation".to_string(), - first_observed: 0, - instance_count: 0, - confidence: 0.5, - derived_from: None, - supersedes: None, - deprecated: false, - }; - register_edge_type(&db, &original).unwrap(); - split_edge_type(&db, "relates_to", "causes", "references", "directionality").unwrap(); - let orig = get_edge_type(&db, "relates_to").unwrap().unwrap(); - assert!(orig.deprecated); - assert!(get_edge_type(&db, "causes").unwrap().is_some()); - assert!(get_edge_type(&db, "references").unwrap().is_some()); - } -} diff --git a/engrams/engram-core/src/error.rs b/engrams/engram-core/src/error.rs deleted file mode 100644 index 9ba4ce7..0000000 --- a/engrams/engram-core/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -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("Node already exists: {0} — nodes are immutable; supersede via edge")] - NodeAlreadyExists(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/engrams/engram-core/src/graph.rs b/engrams/engram-core/src/graph.rs deleted file mode 100644 index e3cce4c..0000000 --- a/engrams/engram-core/src/graph.rs +++ /dev/null @@ -1,98 +0,0 @@ -/// 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}; -use sled::Db; -use std::collections::{HashSet, VecDeque}; -use uuid::Uuid; - -/// Persist a new node and its embedding. Enforces node immutability — returns -/// `EngramError::NodeAlreadyExists` if the ID is already in the store. -pub fn put_node(db: &Db, node: &Node) -> EngramResult { - storage::write_node(db, node)?; - Ok(node.id) -} - -/// Overwrite a node unconditionally. Used internally for salience/tier mutations -/// (touch, decay, consolidation). Do NOT call this for user-visible node creation. -pub fn overwrite_node(db: &Db, node: &Node) -> EngramResult { - storage::overwrite_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(&str)`, only edges whose `relation` field matches -/// that string 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<&str>, - 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(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/engrams/engram-core/src/lib.rs b/engrams/engram-core/src/lib.rs deleted file mode 100644 index bf86d97..0000000 --- a/engrams/engram-core/src/lib.rs +++ /dev/null @@ -1,57 +0,0 @@ -/// 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, EDGE_SUPERSEDES}; -/// 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 consolidation; -pub mod db; -#[cfg(not(feature = "wasm"))] -pub mod edge_type; -pub mod error; -pub mod graph; -pub mod salience; -#[cfg(not(feature = "wasm"))] -pub mod storage; -#[cfg(feature = "wasm")] -pub mod mem_storage; -pub mod types; -pub mod vector; -#[cfg(feature = "migration")] -pub mod migration; - -// Re-export the public surface -pub use db::EngramDb; -pub use error::{EngramError, EngramResult}; -pub use types::{ - ActivatedNode, Edge, EdgeTypeDef, MemoryTier, Node, NodeType, ScoredNode, now_ms, - EDGE_ACTIVATES, EDGE_CAUSES, EDGE_CONTAINS, EDGE_CONTRADICTS, EDGE_EXEMPLIFIES, - EDGE_REFERENCES, EDGE_SUPERSEDES, EDGE_TEMPORALLY_PRECEDES, -}; -pub use consolidation::{ConsolidationConfig, ConsolidationReport}; diff --git a/engrams/engram-core/src/mem_storage.rs b/engrams/engram-core/src/mem_storage.rs deleted file mode 100644 index ac6721b..0000000 --- a/engrams/engram-core/src/mem_storage.rs +++ /dev/null @@ -1,99 +0,0 @@ -/// In-memory storage backend for environments without a filesystem. -/// -/// Used when the `wasm` feature is enabled (e.g. browser via wasm-bindgen). -/// Implements the same logical interface as the sled-backed `storage` module -/// so that `EngramDb` can work identically in both environments. -/// -/// All state lives in a `MemStore` that is held by `EngramDb` under a `RwLock` -/// so concurrent reads are fine and writes are serialised. -use crate::error::{EngramError, EngramResult}; -use crate::types::{Edge, Node}; -use std::collections::HashMap; -use uuid::Uuid; - -#[derive(Default)] -pub struct MemStore { - pub nodes: HashMap, - /// from_id → list of edges - pub edges_from: HashMap>, - /// to_id → list of edges - pub edges_to: HashMap>, -} - -impl MemStore { - pub fn new() -> Self { - Self::default() - } - - // ── Node operations ─────────────────────────────────────────────────────── - - pub fn write_node(&mut self, node: &Node) -> EngramResult<()> { - self.nodes.insert(node.id, node.clone()); - Ok(()) - } - - pub fn read_node(&self, id: Uuid) -> EngramResult> { - Ok(self.nodes.get(&id).cloned()) - } - - pub fn scan_nodes(&self) -> EngramResult> { - Ok(self.nodes.values().cloned().collect()) - } - - pub fn node_count(&self) -> usize { - self.nodes.len() - } - - // ── Edge operations ─────────────────────────────────────────────────────── - - pub fn write_edge(&mut self, edge: &Edge) -> EngramResult<()> { - self.edges_from - .entry(edge.from_id) - .or_default() - .push(edge.clone()); - self.edges_to - .entry(edge.to_id) - .or_default() - .push(edge.clone()); - Ok(()) - } - - pub fn read_edges_from(&self, from_id: Uuid) -> EngramResult> { - Ok(self - .edges_from - .get(&from_id) - .cloned() - .unwrap_or_default()) - } - - pub fn read_edges_to(&self, to_id: Uuid) -> EngramResult> { - Ok(self - .edges_to - .get(&to_id) - .cloned() - .unwrap_or_default()) - } - - pub fn edge_count(&self) -> usize { - self.edges_from.values().map(|v| v.len()).sum() - } - - // ── Vector operations ───────────────────────────────────────────────────── - - pub fn scan_vectors(&self) -> EngramResult)>> { - Ok(self - .nodes - .iter() - .map(|(id, n)| (*id, n.embedding.clone())) - .collect()) - } - - // ── Salience ────────────────────────────────────────────────────────────── - - pub fn write_salience(&mut self, id: Uuid, salience: f32) -> EngramResult<()> { - if let Some(node) = self.nodes.get_mut(&id) { - node.salience = salience; - } - Ok(()) - } -} diff --git a/engrams/engram-core/src/migration.rs b/engrams/engram-core/src/migration.rs deleted file mode 100644 index 1c5aa93..0000000 --- a/engrams/engram-core/src/migration.rs +++ /dev/null @@ -1,423 +0,0 @@ -/// Migration connector — imports Neuron's SQLite database into Engram. -/// -/// Neuron stores memories, knowledge, and graph nodes in a SQLite database. -/// This module reads that database and converts records to Engram nodes and edges. -/// -/// # Schema mapping -/// -/// | Neuron table | Engram node | -/// |----------------------|----------------------------------------------| -/// | `memory_nodes` | `Node { tier: Episodic, node_type: Memory }` | -/// | `knowledge_entries` | `Node { tier: Semantic, node_type: Concept }` | -/// -/// Edges from `graph_edges` are converted using their `edge_type` string directly -/// (normalised to lowercase). Unknown types map to `"references"`. -/// -/// # Embeddings -/// -/// Neuron does not currently expose embeddings through the SQLite schema. -/// Random unit vectors are generated as placeholders. Replace the call to -/// `placeholder_embedding` with your embedding model once the ONNX engine is wired in. -/// -/// TODO: wire in real embeddings from all-MiniLM-L6-v2 via the ONNX runtime. - -use crate::error::{EngramError, EngramResult}; -use crate::types::{Edge, MemoryTier, Node, NodeType}; -use rusqlite::{Connection, OpenFlags}; -use std::collections::HashMap; -use std::path::PathBuf; -use uuid::Uuid; - -// ── Config and report ───────────────────────────────────────────────────────── - -/// Configuration for a Neuron → Engram migration. -pub struct MigrationConfig { - /// Path to `~/.neuron/neuron.db` (or any other Neuron SQLite file). - pub sqlite_path: PathBuf, - /// Path where the new Engram sled store will be created. - pub engram_path: PathBuf, - /// Dimensionality of placeholder embeddings. - /// Default: 384 (matches all-MiniLM-L6-v2). - pub embedding_dim: usize, -} - -impl MigrationConfig { - pub fn new(sqlite_path: PathBuf, engram_path: PathBuf) -> Self { - Self { - sqlite_path, - engram_path, - embedding_dim: 384, - } - } -} - -/// Summary of what was imported during migration. -#[derive(Debug, Default)] -pub struct MigrationReport { - /// Rows imported from `memory_nodes`. - pub memories_migrated: usize, - /// Rows imported from `knowledge_entries`. - pub knowledge_migrated: usize, - /// Edges created from `graph_edges`. - pub edges_created: usize, - /// Non-fatal errors collected during the run. - pub errors: Vec, -} - -// ── Main entry point ────────────────────────────────────────────────────────── - -/// Read the Neuron SQLite database at `config.sqlite_path` and import all -/// records into a new Engram sled store at `config.engram_path`. -/// -/// Returns a `MigrationReport` describing what was imported. -/// -/// Non-fatal errors (e.g. a single unreadable row) are collected in -/// `report.errors` rather than aborting the entire migration. -pub fn migrate_from_neuron(config: &MigrationConfig) -> EngramResult { - let conn = Connection::open_with_flags( - &config.sqlite_path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .map_err(|e| EngramError::InvalidParam(format!("Cannot open SQLite: {e}")))?; - - let engram_db = crate::db::EngramDb::open(&config.engram_path)?; - - let mut report = MigrationReport::default(); - - // Maps Neuron string IDs to the Engram UUIDs we assigned. - let mut id_map: HashMap = HashMap::new(); - - // ── Import memory_nodes ─────────────────────────────────────────────────── - { - let mut stmt = conn - .prepare( - "SELECT id, content, importance, superseded_by, created_at \ - FROM memory_nodes ORDER BY created_at ASC", - ) - .map_err(|e| EngramError::InvalidParam(format!("prepare memory_nodes: {e}")))?; - - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, // id - row.get::<_, String>(1)?, // content - row.get::<_, String>(2)?, // importance - row.get::<_, Option>(3)?, // superseded_by - row.get::<_, i64>(4)?, // created_at - )) - }) - .map_err(|e| EngramError::InvalidParam(format!("query memory_nodes: {e}")))?; - - for row_result in rows { - match row_result { - Ok((neuron_id, content, importance_str, _superseded_by, _created_at)) => { - let importance = importance_string_to_f32(&importance_str); - let embedding = placeholder_embedding(config.embedding_dim); - - let node = Node::new( - NodeType::Memory, - embedding, - content.into_bytes(), - MemoryTier::Episodic, - importance, - ); - - match engram_db.put_node(node.clone()) { - Ok(uuid) => { - id_map.insert(neuron_id, uuid); - report.memories_migrated += 1; - } - Err(e) => { - report.errors.push(format!("put_node memory {neuron_id}: {e}")); - } - } - } - Err(e) => { - report.errors.push(format!("read memory row: {e}")); - } - } - } - } - - // ── Import knowledge_entries ────────────────────────────────────────────── - { - let mut stmt = conn - .prepare( - "SELECT id, title, content, tier, created_at \ - FROM knowledge_entries ORDER BY created_at ASC", - ) - .map_err(|e| EngramError::InvalidParam(format!("prepare knowledge_entries: {e}")))?; - - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, // id - row.get::<_, String>(1)?, // title - row.get::<_, String>(2)?, // content - row.get::<_, String>(3)?, // tier - row.get::<_, i64>(4)?, // created_at - )) - }) - .map_err(|e| EngramError::InvalidParam(format!("query knowledge_entries: {e}")))?; - - for row_result in rows { - match row_result { - Ok((neuron_id, title, body, _tier_str, _created_at)) => { - // Combine title + content as the engram node content. - let combined = format!("{title}\n\n{body}"); - let embedding = placeholder_embedding(config.embedding_dim); - - let node = Node::new( - NodeType::Concept, - embedding, - combined.into_bytes(), - MemoryTier::Semantic, - 0.75, // knowledge is moderately important by default - ); - - match engram_db.put_node(node.clone()) { - Ok(uuid) => { - id_map.insert(neuron_id, uuid); - report.knowledge_migrated += 1; - } - Err(e) => { - report.errors.push(format!( - "put_node knowledge {neuron_id}: {e}" - )); - } - } - } - Err(e) => { - report.errors.push(format!("read knowledge row: {e}")); - } - } - } - } - - // ── Import graph_edges ──────────────────────────────────────────────────── - { - // Only import edges where both endpoints ended up in our id_map. - let mut stmt = conn - .prepare( - "SELECT from_id, to_id, edge_type, weight FROM graph_edges", - ) - .map_err(|e| EngramError::InvalidParam(format!("prepare graph_edges: {e}")))?; - - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, // from_id - row.get::<_, String>(1)?, // to_id - row.get::<_, String>(2)?, // edge_type - row.get::<_, f64>(3)?, // weight - )) - }) - .map_err(|e| EngramError::InvalidParam(format!("query graph_edges: {e}")))?; - - for row_result in rows { - match row_result { - Ok((from_str, to_str, edge_type, weight)) => { - let from_uuid = match id_map.get(&from_str) { - Some(u) => *u, - None => continue, // endpoint not migrated, skip - }; - let to_uuid = match id_map.get(&to_str) { - Some(u) => *u, - None => continue, - }; - - let relation = normalise_edge_type(&edge_type); - let edge = Edge::new(from_uuid, to_uuid, relation, weight as f32); - - match engram_db.put_edge(edge) { - Ok(()) => report.edges_created += 1, - Err(e) => { - report.errors.push(format!( - "put_edge {from_str} → {to_str}: {e}" - )); - } - } - } - Err(e) => { - report.errors.push(format!("read edge row: {e}")); - } - } - } - } - - Ok(report) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Convert Neuron's text importance level to a float score. -fn importance_string_to_f32(importance: &str) -> f32 { - match importance.to_lowercase().as_str() { - "critical" => 1.0, - "high" => 0.85, - "normal" | "medium" => 0.5, - "low" => 0.25, - _ => { - // Try parsing directly as a float. - importance.parse::().unwrap_or(0.5).clamp(0.0, 1.0) - } - } -} - -/// Normalise a Neuron edge type string to a canonical Engram edge type name. -/// -/// Known variants (including old PascalCase forms from the Rust enum era) are -/// mapped to their lowercase canonical names. Unknown types fall back to -/// `"references"` as a safe, non-destructive default. -fn normalise_edge_type(edge_type: &str) -> &'static str { - match edge_type.to_lowercase().as_str() { - "supersedes" | "superseded_by" => "supersedes", - "causes" | "caused_by" => "causes", - "contains" | "contained_by" => "contains", - "references" | "referenced_by" => "references", - "contradicts" => "contradicts", - "exemplifies" | "exemplified_by" => "exemplifies", - "activates" => "activates", - "temporally_precedes" | "temporallyprecedes" | "follows" => "temporally_precedes", - _ => "references", // safe default - } -} - -/// Generate a pseudo-random unit vector of the given dimension as a placeholder embedding. -/// -/// Uses a simple xorshift64 PRNG seeded from the current time. The result is -/// semantically meaningless — it only satisfies the schema requirement that -/// every node has an embedding vector. -/// -/// TODO: replace with actual embeddings from all-MiniLM-L6-v2 via ONNX runtime -/// once the embedding engine is wired in. -pub fn placeholder_embedding(dim: usize) -> Vec { - // Seed from subsecond wall time for reasonable entropy across calls. - let mut state: u64 = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos() as u64 - | 1; // ensure non-zero - - // xorshift64 — no overflow risk, passes statistical tests well enough for placeholders. - let mut xorshift = || -> f32 { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - // Map to [-1, 1] - (state as f32 / u64::MAX as f32) * 2.0 - 1.0 - }; - - let mut raw: Vec = (0..dim).map(|_| xorshift()).collect(); - - // Normalise to unit length. - let norm: f32 = raw.iter().map(|x| x * x).sum::().sqrt(); - if norm > 0.0 { - for x in &mut raw { - *x /= norm; - } - } - raw -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn importance_string_critical() { - assert!((importance_string_to_f32("critical") - 1.0).abs() < 1e-6); - } - - #[test] - fn importance_string_normal() { - assert!((importance_string_to_f32("normal") - 0.5).abs() < 1e-6); - } - - #[test] - fn importance_string_unknown_defaults_to_half() { - assert!((importance_string_to_f32("???") - 0.5).abs() < 1e-6); - } - - #[test] - fn edge_type_supersedes() { - assert_eq!(normalise_edge_type("supersedes"), "supersedes"); - } - - #[test] - fn edge_type_unknown_is_references() { - assert_eq!(normalise_edge_type("foobar"), "references"); - } - - #[test] - fn placeholder_embedding_correct_length() { - let emb = placeholder_embedding(384); - assert_eq!(emb.len(), 384); - } - - #[test] - fn placeholder_embedding_is_unit_vector() { - let emb = placeholder_embedding(128); - let norm: f32 = emb.iter().map(|x| x * x).sum::().sqrt(); - assert!((norm - 1.0).abs() < 1e-4); - } - - #[test] - fn migrate_from_in_memory_db() { - // Build a minimal SQLite DB in a temp dir and migrate it. - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - let engram_path = dir.path().join("engram"); - - // Create minimal Neuron-like schema and insert a couple of rows. - let conn = Connection::open(&db_path).unwrap(); - conn.execute_batch( - "CREATE TABLE memory_nodes ( - id TEXT PRIMARY KEY, content TEXT NOT NULL, importance TEXT NOT NULL DEFAULT 'normal', - superseded_by TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL - ); - CREATE TABLE knowledge_entries ( - id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', tier TEXT NOT NULL DEFAULT 'note', - tags TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL - ); - CREATE TABLE graph_edges ( - from_id TEXT NOT NULL, from_type TEXT NOT NULL, - to_id TEXT NOT NULL, to_type TEXT NOT NULL, - edge_type TEXT NOT NULL, weight REAL NOT NULL DEFAULT 1.0, - PRIMARY KEY (from_id, to_id, edge_type) - );", - ) - .unwrap(); - - conn.execute( - "INSERT INTO memory_nodes (id, content, importance, created_at, updated_at) - VALUES ('mem-1', 'First memory', 'high', 1000, 1000)", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO knowledge_entries (id, title, content, created_at, updated_at) - VALUES ('kn-1', 'Some concept', 'Body text.', 2000, 2000)", - [], - ) - .unwrap(); - - drop(conn); // close before migrating - - let config = MigrationConfig { - sqlite_path: db_path, - engram_path, - embedding_dim: 16, - }; - - let report = migrate_from_neuron(&config).unwrap(); - assert_eq!(report.memories_migrated, 1); - assert_eq!(report.knowledge_migrated, 1); - assert_eq!(report.edges_created, 0); // no edges in the test DB - assert!(report.errors.is_empty()); - } -} diff --git a/engrams/engram-core/src/salience.rs b/engrams/engram-core/src/salience.rs deleted file mode 100644 index d06b43e..0000000 --- a/engrams/engram-core/src/salience.rs +++ /dev/null @@ -1,47 +0,0 @@ -/// 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. -/// -/// ```text -/// 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/engrams/engram-core/src/storage.rs b/engrams/engram-core/src/storage.rs deleted file mode 100644 index 95b368c..0000000 --- a/engrams/engram-core/src/storage.rs +++ /dev/null @@ -1,202 +0,0 @@ -/// 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 -/// edge_types:{name} → bincode-encoded EdgeTypeDef (managed by edge_type.rs) -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 ───────────────────────────────────────────────────────────── - -/// Persist a node. Returns `EngramError::NodeAlreadyExists` if a node with -/// this ID already exists in the store. -/// -/// Nodes are immutable and append-only. To update, create a new node and -/// connect it to the old with a `supersedes` edge. -pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> { - let key = node_key(node.id); - - // Immutability guard — reject writes to existing node IDs. - if db.contains_key(&key)? { - return Err(EngramError::NodeAlreadyExists(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(()) -} - -/// Overwrite a node unconditionally. Used internally for salience/tier updates -/// that must mutate in-place (touch, decay, consolidation). -/// -/// Do NOT expose this in public API — callers should use `write_node` which -/// enforces immutability. -pub(crate) fn overwrite_node(db: &Db, node: &Node) -> EngramResult<()> { - let key = node_key(node.id); - let val = bincode::serialize(node)?; - db.insert(key, val)?; - let vkey = vector_key(node.id); - db.insert(vkey, floats_to_bytes(&node.embedding))?; - 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/engrams/engram-core/src/types.rs b/engrams/engram-core/src/types.rs deleted file mode 100644 index c04da5e..0000000 --- a/engrams/engram-core/src/types.rs +++ /dev/null @@ -1,228 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ── Built-in edge type name constants ───────────────────────────────────────── -// -// String constants for the 8 built-in relation types. Use these on Edge.relation -// to reference known types. Intelligence about when to create new types or how -// to score confidence belongs in el, not here. -pub const EDGE_SUPERSEDES: &str = "supersedes"; -pub const EDGE_CAUSES: &str = "causes"; -pub const EDGE_CONTAINS: &str = "contains"; -pub const EDGE_REFERENCES: &str = "references"; -pub const EDGE_CONTRADICTS: &str = "contradicts"; -pub const EDGE_EXEMPLIFIES: &str = "exemplifies"; -pub const EDGE_ACTIVATES: &str = "activates"; -pub const EDGE_TEMPORALLY_PRECEDES: &str = "temporally_precedes"; - -/// 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 -/// - Custom(String) is an open extension point; el defines new types freely -#[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, - /// Caller-defined node type. el uses this for types Rust does not need to know about. - Custom(String), -} - -/// 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, -} - -/// Metadata record for a named edge type. Dumb data container — Rust stores and -/// returns it. All decisions about confidence thresholds, when to create new -/// types, merge/split logic, and pattern recognition belong in el, not here. -/// -/// Fields like `deprecated`, `derived_from`, and `supersedes` are data. -/// They are set by the caller (el) and stored verbatim. Rust never inspects -/// or acts on them autonomously. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EdgeTypeDef { - /// Stable unique identifier for this edge type record - pub id: Uuid, - /// The canonical name used on edges (e.g. `"causes"`, `"resonates_with"`) - pub name: String, - /// Human-readable description of what this relation means - pub description: String, - /// Unix milliseconds when this type was first registered - pub first_observed: i64, - /// How many edges currently carry this type - pub instance_count: u64, - /// Caller-supplied confidence, 0.0–1.0. Stored as-is; not computed here. - pub confidence: f32, - /// Free-text note about what observation prompted this type's creation. - /// Set by el; stored verbatim. - pub derived_from: Option, - /// Name of the edge type this one replaced, if any. Set by el; stored verbatim. - pub supersedes: Option, - /// When true, this type should no longer be used for new edges. - /// Set by el; stored verbatim. - pub deprecated: bool, -} - -/// 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, - } - } - - /// Override the node's UUID. Used in tests and deserialization helpers. - pub fn with_id(mut self, id: Uuid) -> Self { - self.id = id; - self - } -} - -/// 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. -/// -/// The `relation` field is a free-form string naming the edge type — look up -/// the canonical definition in the `EdgeTypeDef` registry via `edge_type`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Edge { - pub id: Uuid, - pub from_id: Uuid, - pub to_id: Uuid, - /// The edge type name (e.g. `"causes"`, `"resonates_with"`). Matches the - /// `name` field of the corresponding `EdgeTypeDef` in the registry. - pub relation: String, - /// 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: impl Into, weight: f32) -> Self { - let now = now_ms(); - Self { - id: Uuid::new_v4(), - from_id, - to_id, - relation: relation.into(), - 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/engrams/engram-core/src/vector.rs b/engrams/engram-core/src/vector.rs deleted file mode 100644 index 6b7e401..0000000 --- a/engrams/engram-core/src/vector.rs +++ /dev/null @@ -1,330 +0,0 @@ -/// Vector similarity search over stored node embeddings. -/// -/// Strategy: -/// - For < HNSW_THRESHOLD indexed nodes: flat O(n) cosine scan (correct, no deps) -/// - For >= HNSW_THRESHOLD nodes: HNSW approximate nearest-neighbour index -/// -/// The HNSW index is built lazily on first search call when the graph is large -/// enough. A "dirty" flag in sled (`hnsw:dirty`) is set to 1 whenever `put_node` -/// adds an embedding; on the next search the index is rebuilt from the current -/// store. For small graphs (< threshold) the flat scan is always used — it is -/// fast enough and avoids the overhead of HNSW construction. -/// -/// Cosine similarity: cos(θ) = (A · B) / (|A| × |B|) -/// instant-distance expects a *distance* metric (lower = closer), so we expose: -/// distance = 1 − cosine_similarity, clamped to [0, 2] - -#[cfg(feature = "sled-backend")] -use crate::storage; -#[cfg(feature = "sled-backend")] -use sled::Db; - -use crate::error::EngramResult; -use crate::types::{Node, ScoredNode}; -use instant_distance::{Builder, HnswMap, Search}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Minimum number of nodes before we switch from flat scan to HNSW. -const HNSW_THRESHOLD: usize = 100; - -/// sled key used to store the dirty flag (1 = needs rebuild, 0 = clean). -#[cfg(feature = "sled-backend")] -const HNSW_DIRTY_KEY: &[u8] = b"hnsw:dirty"; - -// ── Point wrapper ───────────────────────────────────────────────────────────── - -/// An f32 embedding vector treated as an HNSW point. -/// -/// The distance metric is `1 − cosine_similarity` so that instant-distance -/// (which minimises distance) finds the most similar vectors. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct EmbeddingPoint(pub Vec); - -impl instant_distance::Point for EmbeddingPoint { - fn distance(&self, other: &Self) -> f32 { - let sim = cosine_similarity(&self.0, &other.0); - (1.0 - sim).clamp(0.0, 2.0) - } -} - -// ── Public similarity helper ────────────────────────────────────────────────── - -/// Compute the cosine similarity between two equal-length f32 slices. -/// -/// Returns a value in [-1.0, 1.0], where 1.0 means identical direction. -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) -} - -// ── sled-backed search ──────────────────────────────────────────────────────── - -#[cfg(feature = "sled-backend")] -/// Search all stored embeddings for the `limit` closest nodes to `query`. -/// -/// Falls back to flat scan for stores with < HNSW_THRESHOLD nodes, or when the -/// index has not yet been built. Uses HNSW for large stores. -pub fn search_embedding( - db: &Db, - query: &[f32], - limit: usize, - node_loader: impl Fn(Uuid) -> EngramResult>, -) -> EngramResult> { - let vectors = storage::scan_vectors(db)?; - - if vectors.len() < HNSW_THRESHOLD { - return flat_search(query, limit, &vectors, node_loader); - } - - // Check if the index needs rebuilding. - let dirty = db - .get(HNSW_DIRTY_KEY)? - .map(|v| v.first().copied().unwrap_or(1) != 0) - .unwrap_or(true); - - // We always rebuild if dirty. The index is not serialised to sled because - // HnswMap serialisation size can be large and the rebuild is fast (<10ms - // for typical node counts). The dirty flag is persisted so we skip - // unnecessary rebuilds between searches within the same sled session. - let (map, ids) = build_hnsw_index(&vectors); - - if dirty { - // Clear the dirty flag now that we have a fresh index. - let _ = db.insert(HNSW_DIRTY_KEY, vec![0u8]); - } - - hnsw_search(query, limit, &map, &ids, node_loader) -} - -/// Mark the HNSW index as dirty. Call this after any `put_node`. -#[cfg(feature = "sled-backend")] -pub fn mark_dirty(db: &Db) { - let _ = db.insert(HNSW_DIRTY_KEY, vec![1u8]); -} - -/// Explicitly build and persist the HNSW index. Returns the number of nodes indexed. -/// -/// Not normally needed — the index is built lazily on first search. -/// Call this to pre-warm after a large batch insert. -#[cfg(feature = "sled-backend")] -pub fn build_index(db: &Db) -> EngramResult { - let vectors = storage::scan_vectors(db)?; - let n = vectors.len(); - // Build the index (result is discarded — next search will build from the - // current clean state). - if n > 0 { - let _ = build_hnsw_index(&vectors); - } - let _ = db.insert(HNSW_DIRTY_KEY, vec![0u8]); - Ok(n) -} - -/// Retrieve the stored embedding for a single node by id. -#[cfg(feature = "sled-backend")] -pub fn get_embedding(db: &Db, id: Uuid) -> EngramResult> { - use crate::error::EngramError; - 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)), - } -} - -// ── In-memory search (used by wasm / unit tests) ────────────────────────────── - -/// Search a list of (id, embedding) pairs without a database. -pub fn search_embedding_memory( - query: &[f32], - limit: usize, - vectors: &[(Uuid, Vec)], - node_loader: impl Fn(Uuid) -> EngramResult>, -) -> EngramResult> { - if vectors.len() < HNSW_THRESHOLD { - flat_search(query, limit, vectors, node_loader) - } else { - let (map, ids) = build_hnsw_index(vectors); - hnsw_search(query, limit, &map, &ids, node_loader) - } -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Flat cosine scan — O(n). Used when the graph is small. -fn flat_search( - query: &[f32], - limit: usize, - vectors: &[(Uuid, Vec)], - node_loader: impl Fn(Uuid) -> EngramResult>, -) -> EngramResult> { - let mut scored: Vec<(Uuid, f32)> = vectors - .iter() - .map(|(id, emb)| (*id, cosine_similarity(query, emb))) - .collect(); - - 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) -} - -/// Build an HnswMap from a flat vector list. -fn build_hnsw_index(vectors: &[(Uuid, Vec)]) -> (HnswMap, Vec) { - let points: Vec = vectors - .iter() - .map(|(_, emb)| EmbeddingPoint(emb.clone())) - .collect(); - let ids: Vec = vectors.iter().map(|(id, _)| *id).collect(); - let map = Builder::default().build(points, ids.clone()); - (map, ids) -} - -/// Search using an HnswMap. Converts distance back to cosine similarity score. -fn hnsw_search( - query: &[f32], - limit: usize, - map: &HnswMap, - _ids: &[Uuid], - node_loader: impl Fn(Uuid) -> EngramResult>, -) -> EngramResult> { - let query_point = EmbeddingPoint(query.to_vec()); - let mut search = Search::default(); - - let mut results = Vec::new(); - for item in map.search(&query_point, &mut search).take(limit) { - // distance = 1 − cosine_sim → cosine_sim = 1 − distance - let score = (1.0 - item.distance).clamp(-1.0, 1.0); - let node_id = *item.value; - if let Some(node) = node_loader(node_id)? { - results.push(ScoredNode { node, score }); - } - } - // Ensure descending score order (HNSW returns ascending distance order) - results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); - Ok(results) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{MemoryTier, Node, NodeType}; - - fn dummy_node(id: Uuid) -> Node { - Node::new( - NodeType::Memory, - vec![0.0; 4], - vec![], - MemoryTier::Episodic, - 0.5, - ) - .with_id(id) - } - - #[test] - fn cosine_identical_vectors() { - let v = vec![1.0_f32, 0.0, 0.0, 1.0]; - assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6); - } - - #[test] - fn cosine_orthogonal_vectors() { - let a = vec![1.0_f32, 0.0]; - let b = vec![0.0_f32, 1.0]; - assert!(cosine_similarity(&a, &b).abs() < 1e-6); - } - - #[test] - fn cosine_opposite_vectors() { - let a = vec![1.0_f32, 0.0]; - let b = vec![-1.0_f32, 0.0]; - let sim = cosine_similarity(&a, &b); - assert!((sim - (-1.0)).abs() < 1e-6); - } - - #[test] - fn flat_search_returns_ordered_results() { - let id_best = Uuid::new_v4(); - let id_mid = Uuid::new_v4(); - let id_low = Uuid::new_v4(); - - let query = vec![1.0_f32, 0.0, 0.0, 0.0]; - let vecs: Vec<(Uuid, Vec)> = vec![ - (id_low, vec![0.0, 1.0, 0.0, 0.0]), // sim=0 - (id_best, vec![1.0, 0.0, 0.0, 0.0]), // sim=1 ← best - (id_mid, vec![0.7, 0.7, 0.0, 0.0]), // sim≈0.7 - ]; - - let results = flat_search(&query, 3, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap(); - - assert_eq!(results.len(), 3); - assert_eq!(results[0].node.id, id_best); - assert!(results[0].score > results[1].score); - assert!(results[1].score > results[2].score); - } - - #[test] - fn flat_search_respects_limit() { - let query = vec![1.0_f32, 0.0]; - let vecs: Vec<(Uuid, Vec)> = (0..10) - .map(|i| (Uuid::new_v4(), vec![i as f32, 0.0])) - .collect(); - let results = flat_search(&query, 3, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap(); - assert_eq!(results.len(), 3); - } - - #[test] - fn embedding_point_distance_self_is_zero() { - use instant_distance::Point; - let p = EmbeddingPoint(vec![0.6_f32, 0.8]); - assert!(p.distance(&p) < 1e-5); - } - - #[test] - fn search_memory_small_falls_back_to_flat() { - let vecs: Vec<(Uuid, Vec)> = (0..10) - .map(|i| { - let mut emb = vec![0.0_f32; 8]; - emb[i % 8] = 1.0; - (Uuid::new_v4(), emb) - }) - .collect(); - - let target = vecs[3].clone(); - let query = target.1.clone(); - - let results = - search_embedding_memory(&query, 1, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].node.id, target.0); - assert!((results[0].score - 1.0).abs() < 1e-5); - } - - #[test] - fn cosine_zero_vector_returns_zero() { - let a = vec![0.0_f32, 0.0]; - let b = vec![1.0_f32, 0.0]; - assert_eq!(cosine_similarity(&a, &b), 0.0); - } -} diff --git a/engrams/engram-crypto/Cargo.toml b/engrams/engram-crypto/Cargo.toml deleted file mode 100644 index fd5e8af..0000000 --- a/engrams/engram-crypto/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "engram-crypto" -version = "0.1.0" -edition = "2021" -description = "Quantum-secure encryption at rest for Engram — AES-256-GCM with PQ upgrade path" -license = "MIT" - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -thiserror = "1" -# AES-256-GCM symmetric encryption (quantum-resistant at 256-bit key length) -aes-gcm = "0.10" -# BLAKE3 for key derivation (fast, cryptographically strong) -blake3 = "1" -# Random number generation -rand = "0.8" -# Base64 encoding for serialization (used in EncryptedContent serialization) -base64 = "0.22" - -# TODO: Upgrade to post-quantum KEM/signature once crates stabilize. -# Target: ml-kem (CRYSTALS-Kyber / NIST ML-KEM) and ml-dsa (CRYSTALS-Dilithium / NIST ML-DSA). -# As of 2025, the `ml-kem` and `ml-dsa` crates are available on crates.io but not yet -# production-stable for all platforms. The algorithm registry structure below is designed -# so that the upgrade is a drop-in: add the PQ crate, implement the KemAlgorithm variant, -# and new writes use the new algorithm while old records continue to decrypt via the registry. -# -# Uncomment when ready: -# ml-kem = "0.2" # CRYSTALS-Kyber (NIST ML-KEM 768/1024) -# ml-dsa = "0.1" # CRYSTALS-Dilithium (NIST ML-DSA) - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-crypto/src/algorithm.rs b/engrams/engram-crypto/src/algorithm.rs deleted file mode 100644 index cb761ba..0000000 --- a/engrams/engram-crypto/src/algorithm.rs +++ /dev/null @@ -1,87 +0,0 @@ -/// Algorithm registry types — the versioning layer for crypto algorithm rotation. -/// -/// Each encrypted record carries an `algorithm_id`. The registry maps these IDs -/// to the parameters needed to decrypt. When you rotate algorithms, old records -/// keep their ID and decrypt using the historical version. New records use the -/// new active algorithm. -use serde::{Deserialize, Serialize}; - -/// Key Encapsulation Mechanism algorithms. -/// -/// # Current -/// - `Aes256GcmDirect`: AES-256-GCM with a directly-provided 256-bit key. -/// Quantum-resistant at 256-bit (Grover halves to 128-bit effective security). -/// -/// # Planned (post-quantum upgrade) -/// - `MlKem768`: CRYSTALS-Kyber 768 (NIST ML-KEM Level 3 — 128-bit PQ security) -/// - `MlKem1024`: CRYSTALS-Kyber 1024 (NIST ML-KEM Level 5 — 256-bit PQ security) -/// - `ClassicRsa4096`: RSA-4096 OAEP fallback (NOT quantum-resistant — for compat only) -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum KemAlgorithm { - /// AES-256-GCM with direct key (current default, quantum-resistant at 256-bit) - Aes256GcmDirect, - // TODO: uncomment when ml-kem crate stabilizes - // MlKem768, - // MlKem1024, - // ClassicRsa4096, -} - -impl KemAlgorithm { - pub fn id(&self) -> &'static str { - match self { - KemAlgorithm::Aes256GcmDirect => "aes256gcm-direct-v1", - } - } -} - -/// Signature algorithms for authenticating ciphertext. -/// -/// # Current -/// - `Blake3Mac`: BLAKE3 keyed hash as a MAC (message authentication code). -/// Not a signature in the asymmetric sense, but provides authenticity. -/// -/// # Planned (post-quantum upgrade) -/// - `MlDsa44` / `MlDsa65` / `MlDsa87`: CRYSTALS-Dilithium (NIST ML-DSA) -/// - `SphincsSha256128f`: SPHINCS+ stateless hash-based signature -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum SigAlgorithm { - /// BLAKE3 keyed MAC (current default) - Blake3Mac, - // TODO: uncomment when ml-dsa crate stabilizes - // MlDsa44, // NIST Security Level 2 (128-bit) - // MlDsa65, // NIST Security Level 3 (192-bit) - // MlDsa87, // NIST Security Level 5 (256-bit) - // SphincsSha256128f, // Stateless hash-based, conservative security -} - -impl SigAlgorithm { - pub fn id(&self) -> &'static str { - match self { - SigAlgorithm::Blake3Mac => "blake3-mac-v1", - } - } -} - -/// A versioned algorithm configuration entry. -/// -/// Historical versions are kept in the registry so that old ciphertexts can -/// always be decrypted even after algorithm rotation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AlgorithmVersion { - /// Unique string ID stored alongside every ciphertext. - pub id: String, - /// The KEM algorithm used for key derivation/encapsulation. - pub kem: KemAlgorithm, - /// The signature algorithm used for ciphertext authentication. - pub sig: SigAlgorithm, - /// Unix milliseconds when this version became active. - pub activated_at: i64, - /// Unix milliseconds when this version was superseded (None = still active). - pub retired_at: Option, -} - -impl AlgorithmVersion { - pub fn is_active(&self) -> bool { - self.retired_at.is_none() - } -} diff --git a/engrams/engram-crypto/src/engine.rs b/engrams/engram-crypto/src/engine.rs deleted file mode 100644 index bc65f09..0000000 --- a/engrams/engram-crypto/src/engine.rs +++ /dev/null @@ -1,340 +0,0 @@ -/// CryptoEngine — encrypt and decrypt node content with algorithm versioning. -/// -/// # Security Model -/// -/// - **Symmetric encryption**: AES-256-GCM (authenticated encryption, quantum-resistant) -/// - **Key derivation**: BLAKE3 KDF — stretches the master key into per-operation keys -/// - **Authentication**: BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext) -/// - **Nonces**: 96-bit random nonce per encryption (from OS CSPRNG via `rand`) -/// -/// # AES-256-GCM and Quantum Resistance -/// -/// AES-256 is considered quantum-resistant: Grover's algorithm provides at most -/// a quadratic speedup, reducing 256-bit security to ~128-bit effective security -/// against quantum adversaries. 128-bit quantum security is currently considered -/// sufficient. The algorithm_id in EncryptedContent ensures an upgrade to -/// ML-KEM/Kyber is a transparent drop-in when the crates stabilize. -use aes_gcm::{ - aead::{Aead, AeadCore, KeyInit, OsRng}, - Aes256Gcm, Key, Nonce, -}; -use serde::{Deserialize, Serialize}; - -use crate::error::{CryptoError, CryptoResult}; -use crate::registry::AlgorithmRegistry; - -/// An encrypted content blob, self-describing with its algorithm version. -/// -/// The `algorithm_id` field allows any version to decrypt any record, -/// even after algorithm rotation. This is the key to migration-free upgrades. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EncryptedContent { - /// Which algorithm version encrypted this record. - /// Maps to an entry in `AlgorithmRegistry::versions`. - pub algorithm_id: String, - /// The AES-256-GCM ciphertext (includes the GCM auth tag). - pub ciphertext: Vec, - /// In a PQ scheme: the KEM-encapsulated symmetric key. - /// In the current AES-direct scheme: empty (key is derived from master key + context). - pub encapsulated_key: Vec, - /// 96-bit random AES-GCM nonce. - pub nonce: Vec, - /// BLAKE3 MAC over (algorithm_id || nonce || ciphertext). - /// In a PQ scheme: this would be a Dilithium/ML-DSA signature. - pub signature: Vec, -} - -impl EncryptedContent { - /// Serialize to a compact JSON string for storage. - pub fn to_bytes(&self) -> CryptoResult> { - serde_json::to_vec(self).map_err(|e| CryptoError::Serialization(e.to_string())) - } - - /// Deserialize from bytes (JSON). - pub fn from_bytes(bytes: &[u8]) -> CryptoResult { - serde_json::from_slice(bytes).map_err(|e| CryptoError::DecryptionFailed(e.to_string())) - } -} - -/// The encryption engine. -/// -/// One engine instance per process (or per-request for stateless usage). -/// The engine holds the master key and the algorithm registry. -pub struct CryptoEngine { - /// Master key bytes — 32 bytes for AES-256. - /// In a PQ scheme: this would be a keypair (public/private). - master_key: [u8; 32], - /// Algorithm registry — tracks active and historical versions. - pub registry: AlgorithmRegistry, -} - -impl CryptoEngine { - /// Create an engine from a 32-byte master key (AES-256 requires 256-bit key). - pub fn from_key(key: &[u8]) -> CryptoResult { - if key.len() < 32 { - return Err(CryptoError::InvalidKeyLength { - expected: 32, - got: key.len(), - }); - } - let mut master_key = [0u8; 32]; - master_key.copy_from_slice(&key[..32]); - Ok(Self { - master_key, - registry: AlgorithmRegistry::default_registry(), - }) - } - - /// Create an engine from an environment variable `ENGRAM_ENCRYPTION_KEY`. - /// - /// Returns `None` if the variable is not set (dev mode — plaintext storage). - /// Returns an error if the variable is set but the key is too short. - pub fn from_env() -> CryptoResult> { - match std::env::var("ENGRAM_ENCRYPTION_KEY") { - Ok(key_str) => { - let key_bytes = key_str.as_bytes(); - // Derive a 32-byte key from whatever the user provided - let derived = derive_key(key_bytes, b"engram-master-key"); - Ok(Some(Self::from_key(&derived)?)) - } - Err(std::env::VarError::NotPresent) => Ok(None), - Err(e) => Err(CryptoError::KeyDerivation(e.to_string())), - } - } - - // ── Encrypt ─────────────────────────────────────────────────────────────── - - /// Encrypt `plaintext` using the active algorithm. - /// - /// Returns an `EncryptedContent` that is self-describing: it carries its - /// `algorithm_id`, so decryption never needs out-of-band version tracking. - pub fn encrypt(&self, plaintext: &[u8]) -> CryptoResult { - let algorithm_id = self.registry.active_id().to_string(); - - // Derive a per-operation encryption key from the master key - let enc_key_bytes = derive_key(&self.master_key, b"encrypt"); - let key = Key::::from_slice(&enc_key_bytes); - let cipher = Aes256Gcm::new(key); - - // Random 96-bit nonce - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - - // Encrypt - let ciphertext = cipher - .encrypt(&nonce, plaintext) - .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?; - - // MAC: BLAKE3 keyed hash over (algorithm_id || nonce || ciphertext) - let mac_key = derive_key(&self.master_key, b"mac"); - let signature = compute_mac(&mac_key, &algorithm_id, nonce.as_slice(), &ciphertext); - - Ok(EncryptedContent { - algorithm_id, - ciphertext, - encapsulated_key: vec![], // unused in AES-direct mode - nonce: nonce.to_vec(), - signature, - }) - } - - // ── Decrypt ─────────────────────────────────────────────────────────────── - - /// Decrypt an `EncryptedContent`, dispatching to the correct algorithm version. - pub fn decrypt(&self, content: &EncryptedContent) -> CryptoResult> { - // Look up the algorithm version that produced this ciphertext - let _version = self.registry.get_version(&content.algorithm_id)?; - - // Verify MAC before decrypting (fail-fast on tampering) - let mac_key = derive_key(&self.master_key, b"mac"); - let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext); - if expected != content.signature { - return Err(CryptoError::SignatureInvalid); - } - - // Decrypt - let enc_key_bytes = derive_key(&self.master_key, b"encrypt"); - let key = Key::::from_slice(&enc_key_bytes); - let cipher = Aes256Gcm::new(key); - - if content.nonce.len() != 12 { - return Err(CryptoError::DecryptionFailed(format!( - "invalid nonce length: {}", - content.nonce.len() - ))); - } - let nonce = Nonce::from_slice(&content.nonce); - - let plaintext = cipher - .decrypt(nonce, content.ciphertext.as_slice()) - .map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?; - - Ok(plaintext) - } - - // ── Signature verification ───────────────────────────────────────────────── - - /// Verify the MAC/signature on an encrypted content blob. - pub fn verify_signature(&self, content: &EncryptedContent) -> CryptoResult { - let mac_key = derive_key(&self.master_key, b"mac"); - let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext); - Ok(expected == content.signature) - } - - // ── Algorithm rotation ──────────────────────────────────────────────────── - - /// Rotate to a new KEM algorithm. - /// - /// After rotation, new encryptions use the new algorithm. - /// Old records retain their `algorithm_id` and decrypt via the historical registry. - pub fn rotate_algorithm(&mut self, new_kem: crate::algorithm::KemAlgorithm) -> CryptoResult<()> { - self.registry.rotate_kem(new_kem) - } -} - -// ── Key derivation ──────────────────────────────────────────────────────────── - -/// Derive a 32-byte sub-key from the master key and a context string. -/// Uses BLAKE3's keyed hash for domain separation. -fn derive_key(master: &[u8], context: &[u8]) -> [u8; 32] { - // BLAKE3 derive_key: master is the key material, context is the domain - let mut hasher = blake3::Hasher::new_keyed( - &padded_32(master), - ); - hasher.update(context); - let hash = hasher.finalize(); - *hash.as_bytes() -} - -/// Pad or truncate bytes to exactly 32 bytes. -fn padded_32(bytes: &[u8]) -> [u8; 32] { - let mut out = [0u8; 32]; - let len = bytes.len().min(32); - out[..len].copy_from_slice(&bytes[..len]); - out -} - -/// Compute a BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext). -fn compute_mac(mac_key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec { - let mut hasher = blake3::Hasher::new_keyed(mac_key); - hasher.update(algorithm_id.as_bytes()); - hasher.update(nonce); - hasher.update(ciphertext); - hasher.finalize().as_bytes().to_vec() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_engine() -> CryptoEngine { - let key = b"test-master-key-must-be-32-bytes"; - CryptoEngine::from_key(key).unwrap() - } - - #[test] - fn test_encrypt_decrypt_roundtrip() { - let engine = make_engine(); - let plaintext = b"sensitive memory content - do not store unencrypted"; - let enc = engine.encrypt(plaintext).unwrap(); - let dec = engine.decrypt(&enc).unwrap(); - assert_eq!(dec, plaintext); - } - - #[test] - fn test_nonce_is_random() { - let engine = make_engine(); - let enc1 = engine.encrypt(b"same plaintext").unwrap(); - let enc2 = engine.encrypt(b"same plaintext").unwrap(); - // Nonces must differ (probabilistic — collision probability 1/2^96) - assert_ne!(enc1.nonce, enc2.nonce); - // Ciphertexts must differ (different nonces → different ciphertexts) - assert_ne!(enc1.ciphertext, enc2.ciphertext); - } - - #[test] - fn test_tampered_ciphertext_rejected() { - let engine = make_engine(); - let mut enc = engine.encrypt(b"original").unwrap(); - // Flip a byte in the ciphertext - if let Some(b) = enc.ciphertext.first_mut() { - *b ^= 0xFF; - } - // Decryption should fail due to GCM auth tag verification - let result = engine.decrypt(&enc); - assert!(result.is_err()); - } - - #[test] - fn test_tampered_mac_rejected() { - let engine = make_engine(); - let mut enc = engine.encrypt(b"original").unwrap(); - // Flip a byte in the signature - if let Some(b) = enc.signature.first_mut() { - *b ^= 0xFF; - } - let result = engine.decrypt(&enc); - assert!(result.is_err()); - } - - #[test] - fn test_verify_signature() { - let engine = make_engine(); - let enc = engine.encrypt(b"content").unwrap(); - assert!(engine.verify_signature(&enc).unwrap()); - - let mut tampered = enc.clone(); - tampered.signature[0] ^= 0x01; - assert!(!engine.verify_signature(&tampered).unwrap()); - } - - #[test] - fn test_algorithm_id_stored() { - let engine = make_engine(); - let enc = engine.encrypt(b"data").unwrap(); - assert_eq!(enc.algorithm_id, "aes256gcm-direct-v1"); - } - - #[test] - fn test_serialization_roundtrip() { - let engine = make_engine(); - let enc = engine.encrypt(b"serialize me").unwrap(); - let bytes = enc.to_bytes().unwrap(); - let restored = EncryptedContent::from_bytes(&bytes).unwrap(); - let dec = engine.decrypt(&restored).unwrap(); - assert_eq!(dec, b"serialize me"); - } - - #[test] - fn test_short_key_rejected() { - let short_key = b"too-short"; - let result = CryptoEngine::from_key(short_key); - assert!(result.is_err()); - } - - #[test] - fn test_empty_plaintext() { - let engine = make_engine(); - let enc = engine.encrypt(b"").unwrap(); - let dec = engine.decrypt(&enc).unwrap(); - assert_eq!(dec, b""); - } - - #[test] - fn test_large_plaintext() { - let engine = make_engine(); - let plaintext = vec![0xABu8; 1_000_000]; // 1 MB - let enc = engine.encrypt(&plaintext).unwrap(); - let dec = engine.decrypt(&enc).unwrap(); - assert_eq!(dec, plaintext); - } - - #[test] - fn test_unknown_algorithm_rejected() { - let engine = make_engine(); - let mut enc = engine.encrypt(b"data").unwrap(); - enc.algorithm_id = "unknown-algo-v99".to_string(); - let result = engine.decrypt(&enc); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), CryptoError::UnknownAlgorithm(_))); - } -} diff --git a/engrams/engram-crypto/src/error.rs b/engrams/engram-crypto/src/error.rs deleted file mode 100644 index bc11de6..0000000 --- a/engrams/engram-crypto/src/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum CryptoError { - #[error("Encryption failed: {0}")] - EncryptionFailed(String), - - #[error("Decryption failed: {0}")] - DecryptionFailed(String), - - #[error("Signature verification failed")] - SignatureInvalid, - - #[error("Unknown algorithm ID: {0}")] - UnknownAlgorithm(String), - - #[error("Key derivation failed: {0}")] - KeyDerivation(String), - - #[error("Invalid key length: expected {expected}, got {got}")] - InvalidKeyLength { expected: usize, got: usize }, - - #[error("Algorithm not available: {0}")] - AlgorithmUnavailable(String), - - #[error("Serialization error: {0}")] - Serialization(String), -} - -pub type CryptoResult = Result; diff --git a/engrams/engram-crypto/src/lib.rs b/engrams/engram-crypto/src/lib.rs deleted file mode 100644 index f61f723..0000000 --- a/engrams/engram-crypto/src/lib.rs +++ /dev/null @@ -1,43 +0,0 @@ -/// Engram Crypto — quantum-secure encryption at rest. -/// -/// # Current Implementation -/// -/// Uses AES-256-GCM for symmetric encryption with BLAKE3 for key derivation. -/// AES-256 is already quantum-resistant (Grover's algorithm halves the key space -/// from 2^256 to 2^128, which remains computationally infeasible). -/// -/// # Post-Quantum Upgrade Path -/// -/// The `AlgorithmRegistry` stores an `algorithm_id` alongside every ciphertext. -/// When ML-KEM (CRYSTALS-Kyber) and ML-DSA (CRYSTALS-Dilithium) crates stabilize, -/// the upgrade is: -/// 1. Add `KemAlgorithm::MlKem768` / `MlKem1024` variants -/// 2. Implement `CryptoEngine::encrypt()` for the new algorithm -/// 3. Set it as the active algorithm in the registry -/// 4. Old records continue to decrypt via their stored `algorithm_id` -/// 5. Background re-encryption rotates old records to the new algorithm -/// -/// No data migration required — the registry handles version negotiation. -/// -/// # Usage -/// -/// ```rust,no_run -/// use engram_crypto::{CryptoEngine, AlgorithmRegistry}; -/// -/// let key = b"an-example-32-byte-key!!12345678"; -/// let engine = CryptoEngine::from_key(key).unwrap(); -/// -/// let plaintext = b"sensitive memory content"; -/// let encrypted = engine.encrypt(plaintext).unwrap(); -/// let decrypted = engine.decrypt(&encrypted).unwrap(); -/// assert_eq!(plaintext, decrypted.as_slice()); -/// ``` -pub mod algorithm; -pub mod engine; -pub mod error; -pub mod registry; - -pub use algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm}; -pub use engine::{CryptoEngine, EncryptedContent}; -pub use error::CryptoError; -pub use registry::AlgorithmRegistry; diff --git a/engrams/engram-crypto/src/registry.rs b/engrams/engram-crypto/src/registry.rs deleted file mode 100644 index d007b17..0000000 --- a/engrams/engram-crypto/src/registry.rs +++ /dev/null @@ -1,95 +0,0 @@ -/// Algorithm registry — tracks active and historical algorithm versions. -use std::collections::HashMap; - -use crate::algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm}; -use crate::error::{CryptoError, CryptoResult}; - -/// Maintains the set of algorithm versions known to this node. -/// -/// The active version is used for all new encryptions. -/// Historical versions remain so old ciphertexts can always be decrypted. -pub struct AlgorithmRegistry { - /// The currently active algorithm version. - pub active_kem: KemAlgorithm, - pub active_sig: SigAlgorithm, - /// All known versions (active + historical), keyed by algorithm ID. - pub versions: HashMap, -} - -impl AlgorithmRegistry { - /// Create a registry with the default algorithm (AES-256-GCM + BLAKE3 MAC). - pub fn default_registry() -> Self { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - let default_version = AlgorithmVersion { - id: "aes256gcm-direct-v1".to_string(), - kem: KemAlgorithm::Aes256GcmDirect, - sig: SigAlgorithm::Blake3Mac, - activated_at: now, - retired_at: None, - }; - - let mut versions = HashMap::new(); - versions.insert(default_version.id.clone(), default_version); - - Self { - active_kem: KemAlgorithm::Aes256GcmDirect, - active_sig: SigAlgorithm::Blake3Mac, - versions, - } - } - - /// Get the active algorithm ID (used as the `algorithm_id` in new ciphertexts). - pub fn active_id(&self) -> &str { - self.active_kem.id() - } - - /// Look up a version by its ID (for decryption of historical records). - pub fn get_version(&self, id: &str) -> CryptoResult<&AlgorithmVersion> { - self.versions - .get(id) - .ok_or_else(|| CryptoError::UnknownAlgorithm(id.to_string())) - } - - /// Rotate to a new KEM algorithm. - /// - /// The current active version is marked as retired. A new version entry is - /// added and becomes active. Old records retain their algorithm_id and can - /// still be decrypted via `get_version()`. - /// - /// Background re-encryption can then update old records at leisure. - pub fn rotate_kem(&mut self, new_kem: KemAlgorithm) -> CryptoResult<()> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - // Retire current active version - if let Some(current) = self.versions.get_mut(self.active_kem.id()) { - current.retired_at = Some(now); - } - - let new_id = new_kem.id().to_string(); - let new_version = AlgorithmVersion { - id: new_id.clone(), - kem: new_kem.clone(), - sig: self.active_sig.clone(), - activated_at: now, - retired_at: None, - }; - - self.versions.insert(new_id, new_version); - self.active_kem = new_kem; - Ok(()) - } - - /// List all versions, active and historical. - pub fn list_versions(&self) -> Vec<&AlgorithmVersion> { - let mut vs: Vec<&AlgorithmVersion> = self.versions.values().collect(); - vs.sort_by_key(|v| v.activated_at); - vs - } -} diff --git a/engrams/engram-ffi/Cargo.toml b/engrams/engram-ffi/Cargo.toml deleted file mode 100644 index 76c8465..0000000 --- a/engrams/engram-ffi/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[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"] } - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-ffi/src/lib.rs b/engrams/engram-ffi/src/lib.rs deleted file mode 100644 index 7caa184..0000000 --- a/engrams/engram-ffi/src/lib.rs +++ /dev/null @@ -1,469 +0,0 @@ -/// C FFI for engram-core. -/// -/// These functions form the stable ABI that Go (via CGo), Python (via ctypes), -/// and other native callers use. All pointers must remain valid for the duration -/// of the call. Strings are null-terminated UTF-8. The caller must free any -/// returned heap-allocated C string with `engram_free_string`. -/// -/// # Safety -/// Every function in this module accepts raw pointers and is therefore `unsafe`. -/// Callers must ensure: -/// - All handle pointers came from `engram_open` and have not been freed. -/// - All string pointers are valid null-terminated UTF-8. -/// - Returned C strings are freed exactly once via `engram_free_string`. -use engram_core::{ - ActivatedNode, EngramDb, MemoryTier, Node, NodeType, -}; -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use std::path::Path; -use uuid::Uuid; - -// ── Handle type ─────────────────────────────────────────────────────────────── - -/// Opaque handle wrapping an open `EngramDb`. -pub struct EngramHandle { - db: EngramDb, -} - -// ── Lifecycle ───────────────────────────────────────────────────────────────── - -/// Open or create an engram database at `path`. -/// -/// Returns a heap-allocated `EngramHandle` on success, null on error. -/// Must be freed with `engram_close`. -/// -/// # Safety -/// `path` must be a valid, non-null, 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 handle. -/// -/// After this call `handle` is invalid. -/// -/// # 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)); - } -} - -// ── Statistics ──────────────────────────────────────────────────────────────── - -/// Return the total number of nodes. 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; - } - (*handle).db.node_count().map(|n| n as i64).unwrap_or(-1) -} - -/// Return the total number of edges. 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; - } - (*handle).db.edge_count().map(|n| n as i64).unwrap_or(-1) -} - -// ── Salience management ─────────────────────────────────────────────────────── - -/// Apply multiplicative decay to all node saliences. -/// -/// `factor` should be in (0.0, 1.0). Returns 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; - } - (*handle).db.decay(factor).map(|n| n as i64).unwrap_or(-1) -} - -// ── Node operations ─────────────────────────────────────────────────────────── - -/// Store a node from a JSON representation. -/// -/// `json` must be a UTF-8 JSON object with at least: -/// `{ "content": "...", "node_type": "Memory"|"Concept"|..., "tier": "Episodic"|..., -/// "importance": 0.8, "embedding": [f32, ...] }` -/// -/// Returns a heap-allocated UUID string on success, null on error. -/// Caller must free with `engram_free_string`. -/// -/// # Safety -/// `handle` and `json` must be valid non-null pointers. -#[no_mangle] -pub unsafe extern "C" fn engram_put_node( - handle: *mut EngramHandle, - json: *const c_char, -) -> *mut c_char { - if handle.is_null() || json.is_null() { - return std::ptr::null_mut(); - } - let json_str = match CStr::from_ptr(json).to_str() { - Ok(s) => s, - Err(_) => return std::ptr::null_mut(), - }; - - let node = match node_from_json(json_str) { - Some(n) => n, - None => return std::ptr::null_mut(), - }; - - match (*handle).db.put_node(node) { - Ok(id) => match CString::new(id.to_string()) { - Ok(s) => s.into_raw(), - Err(_) => std::ptr::null_mut(), - }, - Err(_) => std::ptr::null_mut(), - } -} - -/// Retrieve a node by UUID and return it as JSON. -/// -/// `id` must be a UUID string. Returns heap-allocated JSON on success, null if -/// not found or on error. Caller must free with `engram_free_string`. -/// -/// # Safety -/// `handle` and `id` must be valid non-null pointers. -#[no_mangle] -pub unsafe extern "C" fn engram_get_node( - handle: *const EngramHandle, - id: *const c_char, -) -> *mut c_char { - if handle.is_null() || id.is_null() { - return std::ptr::null_mut(); - } - let id_str = match CStr::from_ptr(id).to_str() { - Ok(s) => s, - Err(_) => return std::ptr::null_mut(), - }; - let uuid = match id_str.parse::() { - Ok(u) => u, - Err(_) => return std::ptr::null_mut(), - }; - match (*handle).db.get_node(uuid) { - Ok(Some(node)) => match CString::new(node_to_json(&node)) { - Ok(s) => s.into_raw(), - Err(_) => std::ptr::null_mut(), - }, - _ => std::ptr::null_mut(), - } -} - -// ── Spreading activation ────────────────────────────────────────────────────── - -/// Run spreading activation and return results as JSON. -/// -/// `req_json` must be: -/// `{ "seeds": ["uuid", ...], "query_embedding": [f32, ...], -/// "max_depth": 3, "limit": 10 }` -/// -/// Returns heap-allocated JSON array of `ActivatedNode` objects, or null. -/// Caller must free with `engram_free_string`. -/// -/// # Safety -/// `handle` and `req_json` must be valid non-null pointers. -#[no_mangle] -pub unsafe extern "C" fn engram_activate( - handle: *const EngramHandle, - req_json: *const c_char, -) -> *mut c_char { - if handle.is_null() || req_json.is_null() { - return std::ptr::null_mut(); - } - let json_str = match CStr::from_ptr(req_json).to_str() { - Ok(s) => s, - Err(_) => return std::ptr::null_mut(), - }; - - let (seeds, query_emb, max_depth, limit) = match parse_activate_request(json_str) { - Some(r) => r, - None => return std::ptr::null_mut(), - }; - - match (*handle).db.activate(&seeds, &query_emb, max_depth, limit) { - Ok(results) => { - let json = activated_nodes_to_json(&results); - match CString::new(json) { - Ok(s) => s.into_raw(), - Err(_) => std::ptr::null_mut(), - } - } - Err(_) => std::ptr::null_mut(), - } -} - -/// Free a C string returned by any engram FFI function. -/// -/// # Safety -/// `s` must have been allocated by an engram FFI function. Do not call twice. -#[no_mangle] -pub unsafe extern "C" fn engram_free_string(s: *mut c_char) { - if !s.is_null() { - drop(CString::from_raw(s)); - } -} - -// ── JSON helpers ────────────────────────────────────────────────────────────── -// Minimal hand-rolled JSON to avoid adding serde_json as a dependency. -// These are intentionally simple — they handle the subset we need. - -fn node_from_json(json: &str) -> Option { - // Extract fields with simple string scanning. - let content = extract_string(json, "content").unwrap_or_default(); - let node_type_str = extract_string(json, "node_type").unwrap_or_else(|| "Memory".into()); - let tier_str = extract_string(json, "tier").unwrap_or_else(|| "Episodic".into()); - let importance: f32 = extract_number(json, "importance").unwrap_or(0.5); - let embedding = extract_float_array(json, "embedding").unwrap_or_default(); - - let node_type = match node_type_str.as_str() { - "Concept" => NodeType::Concept, - "Event" => NodeType::Event, - "Entity" => NodeType::Entity, - "Process" => NodeType::Process, - "InternalState" => NodeType::InternalState, - _ => NodeType::Memory, - }; - - let tier = match tier_str.as_str() { - "Working" => MemoryTier::Working, - "Semantic" => MemoryTier::Semantic, - "Procedural" => MemoryTier::Procedural, - _ => MemoryTier::Episodic, - }; - - Some(Node::new(node_type, embedding, content.into_bytes(), tier, importance)) -} - -fn node_to_json(node: &Node) -> String { - let content = String::from_utf8_lossy(&node.content); - let node_type = format!("{:?}", node.node_type); - let tier = format!("{:?}", node.tier); - let emb_str = node - .embedding - .iter() - .map(|f| format!("{:.6}", f)) - .collect::>() - .join(","); - format!( - r#"{{"id":"{}","node_type":"{}","tier":"{}","content":"{}","salience":{:.6},"importance":{:.6},"activation_count":{},"embedding":[{}]}}"#, - node.id, - node_type, - tier, - content.replace('"', "\\\""), - node.salience, - node.importance, - node.activation_count, - emb_str, - ) -} - -fn activated_nodes_to_json(nodes: &[ActivatedNode]) -> String { - let items: Vec = nodes - .iter() - .map(|a| { - format!( - r#"{{"node":{},"activation_strength":{:.6},"hops":{}}}"#, - node_to_json(&a.node), - a.activation_strength, - a.hops, - ) - }) - .collect(); - format!("[{}]", items.join(",")) -} - -fn parse_activate_request(json: &str) -> Option<(Vec, Vec, u8, usize)> { - let seeds_raw = extract_string_array(json, "seeds")?; - let seeds: Vec = seeds_raw - .iter() - .filter_map(|s| s.parse::().ok()) - .collect(); - let query_emb = extract_float_array(json, "query_embedding")?; - let max_depth = extract_number(json, "max_depth").unwrap_or(3.0) as u8; - let limit = extract_number(json, "limit").unwrap_or(10.0) as usize; - Some((seeds, query_emb, max_depth, limit)) -} - -// ── Tiny JSON field extractors ──────────────────────────────────────────────── - -fn extract_string(json: &str, key: &str) -> Option { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - if !rest.starts_with('"') { - return None; - } - let inner = &rest[1..]; - let end = inner.find('"')?; - Some(inner[..end].to_string()) -} - -fn extract_number(json: &str, key: &str) -> Option { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - let end = rest - .find(|c: char| c == ',' || c == '}' || c == ']') - .unwrap_or(rest.len()); - rest[..end].trim().parse::().ok() -} - -fn extract_float_array(json: &str, key: &str) -> Option> { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - if !rest.starts_with('[') { - return None; - } - let end = rest.find(']')?; - let inner = &rest[1..end]; - let floats: Vec = inner - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect(); - Some(floats) -} - -fn extract_string_array(json: &str, key: &str) -> Option> { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - if !rest.starts_with('[') { - return None; - } - let end = rest.find(']')?; - let inner = &rest[1..end]; - let strings: Vec = inner - .split(',') - .filter_map(|s| { - let s = s.trim(); - if s.starts_with('"') && s.ends_with('"') { - Some(s[1..s.len() - 1].to_string()) - } else { - None - } - }) - .collect(); - Some(strings) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::CString; - - #[test] - fn open_and_close() { - let dir = tempfile::tempdir().unwrap(); - let path = CString::new(dir.path().to_str().unwrap()).unwrap(); - unsafe { - let handle = engram_open(path.as_ptr()); - assert!(!handle.is_null()); - engram_close(handle); - } - } - - #[test] - fn null_path_returns_null() { - unsafe { - let handle = engram_open(std::ptr::null()); - assert!(handle.is_null()); - } - } - - #[test] - fn put_and_get_node_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let path = CString::new(dir.path().to_str().unwrap()).unwrap(); - let json = CString::new( - r#"{"content":"hello","node_type":"Memory","tier":"Episodic","importance":0.8,"embedding":[0.1,0.2,0.3]}"#, - ) - .unwrap(); - - unsafe { - let handle = engram_open(path.as_ptr()); - assert!(!handle.is_null()); - - let uuid_ptr = engram_put_node(handle, json.as_ptr()); - assert!(!uuid_ptr.is_null()); - - let uuid_str = CStr::from_ptr(uuid_ptr).to_str().unwrap().to_string(); - engram_free_string(uuid_ptr); - - // Now get the node back. - let id_cstr = CString::new(uuid_str).unwrap(); - let node_json_ptr = engram_get_node(handle, id_cstr.as_ptr()); - assert!(!node_json_ptr.is_null()); - - let node_json = CStr::from_ptr(node_json_ptr).to_str().unwrap().to_string(); - assert!(node_json.contains("hello")); - engram_free_string(node_json_ptr); - - assert_eq!(engram_node_count(handle), 1); - engram_close(handle); - } - } - - #[test] - fn node_count_and_edge_count() { - let dir = tempfile::tempdir().unwrap(); - let path = CString::new(dir.path().to_str().unwrap()).unwrap(); - unsafe { - let handle = engram_open(path.as_ptr()); - assert_eq!(engram_node_count(handle), 0); - assert_eq!(engram_edge_count(handle), 0); - engram_close(handle); - } - } - - #[test] - fn extract_string_works() { - let json = r#"{"content":"hello world","importance":0.5}"#; - assert_eq!(extract_string(json, "content"), Some("hello world".into())); - } - - #[test] - fn extract_number_works() { - let json = r#"{"importance":0.75,"other":1}"#; - let v = extract_number(json, "importance").unwrap(); - assert!((v - 0.75).abs() < 1e-4); - } - - #[test] - fn extract_float_array_works() { - let json = r#"{"embedding":[0.1,0.2,0.3]}"#; - let arr = extract_float_array(json, "embedding").unwrap(); - assert_eq!(arr.len(), 3); - assert!((arr[0] - 0.1).abs() < 1e-4); - } -} diff --git a/engrams/engram-jni/Cargo.toml b/engrams/engram-jni/Cargo.toml deleted file mode 100644 index 2ad0926..0000000 --- a/engrams/engram-jni/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "engram-jni" -version = "0.1.0" -edition = "2021" -description = "JNI bindings for engram-core (Kotlin/Android)" -license = "MIT" - -[lib] -name = "engram_jni" -crate-type = ["cdylib"] - -[dependencies] -engram-core = { path = "../engram-core" } -jni = "0.21" -uuid = { version = "1", features = ["v4", "serde"] } -serde_json = "1" - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-jni/src/lib.rs b/engrams/engram-jni/src/lib.rs deleted file mode 100644 index 6dc2523..0000000 --- a/engrams/engram-jni/src/lib.rs +++ /dev/null @@ -1,496 +0,0 @@ -/// JNI bindings for engram-core. -/// -/// These functions expose the Engram API to Kotlin/JVM callers via Java Native Interface. -/// The convention is: -/// -/// Java___ -/// → Java_ai_neuron_engram_EngramDb_ -/// -/// The `EngramDb` handle is stored as a Java `long` (native pointer). The Kotlin -/// wrapper class casts it to/from `Long` and keeps it private. -/// -/// # Memory model -/// - `open` allocates an `EngramHandle` on the Rust heap and returns its address as `jlong`. -/// - `close` takes that `jlong`, reconstructs the Box, and drops it. -/// - All other methods borrow the handle via `&*ptr`. -/// -/// # JSON wire format -/// Nodes and results are passed as JSON strings to avoid bespoke JNI object marshalling. -/// The Kotlin layer converts between the data classes and JSON. -use engram_core::{ActivatedNode, EngramDb, MemoryTier, Node, NodeType, ScoredNode}; -use jni::objects::{JClass, JString}; -use jni::sys::{jfloatArray, jint, jlong, jstring}; -use jni::JNIEnv; -use std::path::Path; -use uuid::Uuid; - -// ── Handle ──────────────────────────────────────────────────────────────────── - -struct EngramHandle { - db: EngramDb, -} - -// ── Helper macros ───────────────────────────────────────────────────────────── - -macro_rules! handle_ref { - ($handle:expr) => { - unsafe { &*($handle as *const EngramHandle) } - }; -} - -// ── JNI methods: lifecycle ──────────────────────────────────────────────────── - -/// Open an engram database and return a native handle as jlong. -/// -/// Kotlin: `external fun open(path: String): Long` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_open( - mut env: JNIEnv, - _class: JClass, - path: JString, -) -> jlong { - let path_str: String = match env.get_string(&path) { - Ok(s) => s.into(), - Err(_) => return 0, - }; - match EngramDb::open(Path::new(&path_str)) { - Ok(db) => { - let handle = Box::new(EngramHandle { db }); - Box::into_raw(handle) as jlong - } - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - 0 - } - } -} - -/// Close and free a database handle. -/// -/// Kotlin: `external fun close(handle: Long)` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_close( - _env: JNIEnv, - _class: JClass, - handle: jlong, -) { - if handle != 0 { - unsafe { - drop(Box::from_raw(handle as *mut EngramHandle)); - } - } -} - -// ── JNI methods: statistics ─────────────────────────────────────────────────── - -/// Kotlin: `external fun nodeCount(handle: Long): Long` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_nodeCount( - mut env: JNIEnv, - _class: JClass, - handle: jlong, -) -> jlong { - let h = handle_ref!(handle); - match h.db.node_count() { - Ok(n) => n as jlong, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - -1 - } - } -} - -/// Kotlin: `external fun edgeCount(handle: Long): Long` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_edgeCount( - mut env: JNIEnv, - _class: JClass, - handle: jlong, -) -> jlong { - let h = handle_ref!(handle); - match h.db.edge_count() { - Ok(n) => n as jlong, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - -1 - } - } -} - -// ── JNI methods: nodes ──────────────────────────────────────────────────────── - -/// Store a node from JSON and return the assigned UUID string. -/// -/// Kotlin: `external fun putNode(handle: Long, nodeJson: String): String` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_putNode( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - node_json: JString, -) -> jstring { - let json: String = match env.get_string(&node_json) { - Ok(s) => s.into(), - Err(_) => return std::ptr::null_mut(), - }; - - let node = match node_from_json(&json) { - Some(n) => n, - None => { - let _ = env.throw_new("java/lang/IllegalArgumentException", "Invalid node JSON"); - return std::ptr::null_mut(); - } - }; - - let h = handle_ref!(handle); - match h.db.put_node(node) { - Ok(id) => { - let id_str = id.to_string(); - env.new_string(&id_str) - .map(|s| s.into_raw()) - .unwrap_or(std::ptr::null_mut()) - } - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - std::ptr::null_mut() - } - } -} - -/// Retrieve a node by UUID, returned as JSON, or null if not found. -/// -/// Kotlin: `external fun getNode(handle: Long, id: String): String?` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_getNode( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - id: JString, -) -> jstring { - let id_str: String = match env.get_string(&id) { - Ok(s) => s.into(), - Err(_) => return std::ptr::null_mut(), - }; - let uuid = match id_str.parse::() { - Ok(u) => u, - Err(_) => return std::ptr::null_mut(), - }; - - let h = handle_ref!(handle); - match h.db.get_node(uuid) { - Ok(Some(node)) => { - let json = node_to_json(&node); - env.new_string(&json) - .map(|s| s.into_raw()) - .unwrap_or(std::ptr::null_mut()) - } - Ok(None) => std::ptr::null_mut(), - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - std::ptr::null_mut() - } - } -} - -// ── JNI methods: search ─────────────────────────────────────────────────────── - -/// Search for similar nodes by embedding vector. -/// Returns a JSON array of scored nodes. -/// -/// Kotlin: `external fun searchEmbedding(handle: Long, embedding: FloatArray, limit: Int): String` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_searchEmbedding( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - embedding: jfloatArray, - limit: jint, -) -> jstring { - let emb = match float_array_from_jni(&mut env, embedding) { - Some(v) => v, - None => return std::ptr::null_mut(), - }; - - let h = handle_ref!(handle); - match h.db.search_embedding(&emb, limit as usize) { - Ok(results) => { - let json = scored_nodes_to_json(&results); - env.new_string(&json) - .map(|s| s.into_raw()) - .unwrap_or(std::ptr::null_mut()) - } - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - std::ptr::null_mut() - } - } -} - -/// Run spreading activation. -/// `seeds_json` is a JSON array of UUID strings. -/// Returns a JSON array of activated nodes. -/// -/// Kotlin: `external fun activate(handle: Long, seedsJson: String, queryEmbedding: FloatArray, maxDepth: Int, limit: Int): String` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_activate( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - seeds_json: JString, - query_embedding: jfloatArray, - max_depth: jint, - limit: jint, -) -> jstring { - let seeds_str: String = match env.get_string(&seeds_json) { - Ok(s) => s.into(), - Err(_) => return std::ptr::null_mut(), - }; - - let seeds: Vec = parse_uuid_array(&seeds_str); - let query_emb = match float_array_from_jni(&mut env, query_embedding) { - Some(v) => v, - None => return std::ptr::null_mut(), - }; - - let h = handle_ref!(handle); - match h.db.activate(&seeds, &query_emb, max_depth as u8, limit as usize) { - Ok(results) => { - let json = activated_nodes_to_json(&results); - env.new_string(&json) - .map(|s| s.into_raw()) - .unwrap_or(std::ptr::null_mut()) - } - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - std::ptr::null_mut() - } - } -} - -// ── JNI methods: salience ───────────────────────────────────────────────────── - -/// Touch a node (increment activation count and update salience). -/// -/// Kotlin: `external fun touch(handle: Long, id: String)` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_touch( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - id: JString, -) { - let id_str: String = match env.get_string(&id) { - Ok(s) => s.into(), - Err(_) => return, - }; - let uuid = match id_str.parse::() { - Ok(u) => u, - Err(_) => return, - }; - let h = handle_ref!(handle); - if let Err(e) = h.db.touch(uuid) { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - } -} - -/// Apply salience decay. Returns the number of nodes updated. -/// -/// Kotlin: `external fun decay(handle: Long, factor: Float): Int` -#[no_mangle] -pub extern "system" fn Java_ai_neuron_engram_EngramDb_decay( - mut env: JNIEnv, - _class: JClass, - handle: jlong, - factor: f32, -) -> jint { - let h = handle_ref!(handle); - match h.db.decay(factor) { - Ok(n) => n as jint, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e.to_string()); - -1 - } - } -} - -// ── JNI helpers ─────────────────────────────────────────────────────────────── - -fn float_array_from_jni(env: &mut JNIEnv, arr: jfloatArray) -> Option> { - if arr.is_null() { - return None; - } - let arr_obj = unsafe { jni::objects::JFloatArray::from_raw(arr) }; - let len = env.get_array_length(&arr_obj).ok()? as usize; - let mut buf = vec![0f32; len]; - env.get_float_array_region(&arr_obj, 0, &mut buf).ok()?; - Some(buf) -} - -fn parse_uuid_array(json: &str) -> Vec { - // Minimal parser: `["uuid1","uuid2",...]` - json.trim_matches(|c| c == '[' || c == ']') - .split(',') - .filter_map(|s| { - let s = s.trim().trim_matches('"'); - s.parse::().ok() - }) - .collect() -} - -// ── JSON helpers ────────────────────────────────────────────────────────────── - -fn node_from_json(json: &str) -> Option { - let content = extract_string_field(json, "content").unwrap_or_default(); - let node_type_str = - extract_string_field(json, "node_type").unwrap_or_else(|| "Memory".into()); - let tier_str = extract_string_field(json, "tier").unwrap_or_else(|| "Episodic".into()); - let importance: f32 = extract_f32_field(json, "importance").unwrap_or(0.5); - let embedding = extract_f32_array(json, "embedding").unwrap_or_default(); - - let node_type = match node_type_str.as_str() { - "Concept" => NodeType::Concept, - "Event" => NodeType::Event, - "Entity" => NodeType::Entity, - "Process" => NodeType::Process, - "InternalState" => NodeType::InternalState, - _ => NodeType::Memory, - }; - let tier = match tier_str.as_str() { - "Working" => MemoryTier::Working, - "Semantic" => MemoryTier::Semantic, - "Procedural" => MemoryTier::Procedural, - _ => MemoryTier::Episodic, - }; - Some(Node::new(node_type, embedding, content.into_bytes(), tier, importance)) -} - -fn node_to_json(node: &Node) -> String { - let content = String::from_utf8_lossy(&node.content) - .replace('\\', "\\\\") - .replace('"', "\\\""); - let emb_str = node - .embedding - .iter() - .map(|f| format!("{:.6}", f)) - .collect::>() - .join(","); - format!( - r#"{{"id":"{}","node_type":"{:?}","tier":"{:?}","content":"{}","salience":{:.6},"importance":{:.6},"activation_count":{},"embedding":[{}]}}"#, - node.id, node.node_type, node.tier, content, node.salience, node.importance, - node.activation_count, emb_str, - ) -} - -fn scored_nodes_to_json(nodes: &[ScoredNode]) -> String { - let items: Vec = nodes - .iter() - .map(|s| format!(r#"{{"node":{},"score":{:.6}}}"#, node_to_json(&s.node), s.score)) - .collect(); - format!("[{}]", items.join(",")) -} - -fn activated_nodes_to_json(nodes: &[ActivatedNode]) -> String { - let items: Vec = nodes - .iter() - .map(|a| { - format!( - r#"{{"node":{},"activation_strength":{:.6},"hops":{}}}"#, - node_to_json(&a.node), a.activation_strength, a.hops, - ) - }) - .collect(); - format!("[{}]", items.join(",")) -} - -fn extract_string_field(json: &str, key: &str) -> Option { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - if !rest.starts_with('"') { - return None; - } - let inner = &rest[1..]; - let end = inner.find('"')?; - Some(inner[..end].to_string()) -} - -fn extract_f32_field(json: &str, key: &str) -> Option { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - let end = rest - .find(|c: char| c == ',' || c == '}') - .unwrap_or(rest.len()); - rest[..end].trim().parse::().ok() -} - -fn extract_f32_array(json: &str, key: &str) -> Option> { - let needle = format!("\"{}\":", key); - let start = json.find(&needle)? + needle.len(); - let rest = json[start..].trim_start(); - if !rest.starts_with('[') { - return None; - } - let end = rest.find(']')?; - let inner = &rest[1..end]; - Some( - inner - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect(), - ) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn node_json_roundtrip() { - let node = Node::new( - NodeType::Memory, - vec![0.1, 0.2, 0.3], - b"test content".to_vec(), - MemoryTier::Episodic, - 0.8, - ); - let json = node_to_json(&node); - assert!(json.contains("test content")); - assert!(json.contains("Memory")); - assert!(json.contains("Episodic")); - } - - #[test] - fn parse_uuid_array_valid() { - let uuids = parse_uuid_array(r#"["550e8400-e29b-41d4-a716-446655440000"]"#); - assert_eq!(uuids.len(), 1); - } - - #[test] - fn parse_uuid_array_empty() { - let uuids = parse_uuid_array("[]"); - assert_eq!(uuids.len(), 0); - } - - #[test] - fn extract_string_field_works() { - let json = r#"{"content":"hello","type":"Memory"}"#; - assert_eq!(extract_string_field(json, "content"), Some("hello".into())); - assert_eq!(extract_string_field(json, "type"), Some("Memory".into())); - } - - #[test] - fn extract_f32_array_works() { - let json = r#"{"embedding":[0.1,0.2,0.3]}"#; - let arr = extract_f32_array(json, "embedding").unwrap(); - assert_eq!(arr.len(), 3); - } - - #[test] - fn activated_nodes_json_is_array() { - let json = activated_nodes_to_json(&[]); - assert_eq!(json, "[]"); - } -} diff --git a/engrams/engram-migrate/Cargo.toml b/engrams/engram-migrate/Cargo.toml deleted file mode 100644 index 3986ae4..0000000 --- a/engrams/engram-migrate/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "engram-migrate" -version = "0.1.0" -edition = "2021" -description = "CLI tool: migrate a Neuron SQLite database into an Engram sled store" -license = "MIT" - -[[bin]] -name = "engram-migrate" -path = "src/main.rs" - -[dependencies] -engram-core = { path = "../engram-core", features = ["sled-backend", "migration"] } diff --git a/engrams/engram-migrate/src/main.rs b/engrams/engram-migrate/src/main.rs deleted file mode 100644 index d9520d3..0000000 --- a/engrams/engram-migrate/src/main.rs +++ /dev/null @@ -1,105 +0,0 @@ -/// engram-migrate — import a Neuron SQLite database into an Engram sled store. -/// -/// Usage: -/// engram-migrate --sqlite ~/.neuron/neuron.db --output ~/.engram/neuron -/// -/// The tool reads memory_nodes, knowledge_entries, and graph_edges from the -/// Neuron SQLite database and writes them to a new Engram sled store. -/// -/// Embeddings are placeholder random unit vectors (dimension 384 by default). -/// Re-run with a real embedding model once the ONNX engine is available. -use engram_core::migration::{migrate_from_neuron, MigrationConfig}; -use std::path::PathBuf; -use std::process; - -fn main() { - let args: Vec = std::env::args().collect(); - - if args.len() < 5 { - eprintln!("Usage: engram-migrate --sqlite --output "); - eprintln!(" --sqlite Path to the Neuron SQLite database (e.g. ~/.neuron/neuron.db)"); - eprintln!(" --output Path for the new Engram sled store (e.g. ~/.engram/neuron)"); - process::exit(1); - } - - let mut sqlite_path: Option = None; - let mut output_path: Option = None; - let mut embedding_dim: usize = 384; - - let mut i = 1; - while i < args.len() { - match args[i].as_str() { - "--sqlite" => { - i += 1; - sqlite_path = Some(PathBuf::from(&args[i])); - } - "--output" => { - i += 1; - output_path = Some(PathBuf::from(&args[i])); - } - "--embedding-dim" => { - i += 1; - embedding_dim = args[i].parse().unwrap_or(384); - } - _ => { - eprintln!("Unknown argument: {}", args[i]); - process::exit(1); - } - } - i += 1; - } - - let sqlite_path = match sqlite_path { - Some(p) => p, - None => { - eprintln!("Missing --sqlite argument"); - process::exit(1); - } - }; - - let output_path = match output_path { - Some(p) => p, - None => { - eprintln!("Missing --output argument"); - process::exit(1); - } - }; - - if !sqlite_path.exists() { - eprintln!("SQLite file not found: {}", sqlite_path.display()); - process::exit(1); - } - - println!("Migrating Neuron database..."); - println!(" Source: {}", sqlite_path.display()); - println!(" Output: {}", output_path.display()); - println!(" Embedding dim: {}", embedding_dim); - println!(); - - let config = MigrationConfig { - sqlite_path, - engram_path: output_path, - embedding_dim, - }; - - match migrate_from_neuron(&config) { - Ok(report) => { - println!("Migration complete."); - println!(" Memories migrated: {}", report.memories_migrated); - println!(" Knowledge migrated: {}", report.knowledge_migrated); - println!(" Edges created: {}", report.edges_created); - - if !report.errors.is_empty() { - println!(); - println!("Non-fatal errors ({}):", report.errors.len()); - for e in &report.errors { - println!(" - {}", e); - } - } - } - Err(e) => { - eprintln!("Migration failed: {}", e); - process::exit(1); - } - } -} diff --git a/engrams/engram-projection/Cargo.toml b/engrams/engram-projection/Cargo.toml deleted file mode 100644 index cea9859..0000000 --- a/engrams/engram-projection/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "engram-projection" -version = "0.1.0" -edition = "2021" -description = "Schema/projection layer for Engram — schema-free views over the activation surface" -license = "MIT" - -[dependencies] -engram-core = { path = "../engram-core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -thiserror = "1" -base64 = "0.22" - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-projection/src/engine.rs b/engrams/engram-projection/src/engine.rs deleted file mode 100644 index bef75e4..0000000 --- a/engrams/engram-projection/src/engine.rs +++ /dev/null @@ -1,463 +0,0 @@ -/// Projection engine — maps activated nodes through a ProjectionSchema. -/// -/// The engine is stateless: it takes a schema and a result set, and returns -/// the projected view. No mutation of the underlying graph occurs. -use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use engram_core::types::{ActivatedNode, NodeType}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use uuid::Uuid; - -use crate::error::ProjectionResult; -use crate::schema::{ - FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult as PResult, - ProjectionSchema, ProjectionType, -}; - -/// Stateless projection executor. -pub struct ProjectionEngine; - -impl ProjectionEngine { - /// Apply a projection schema to a set of activated nodes. - /// - /// Returns a `ProjectionResult` containing the shaped output. - /// The activation result set is not modified. - pub fn project( - schema: &ProjectionSchema, - activated: &[ActivatedNode], - ) -> ProjectionResult { - // Step 1: filter to in-scope nodes - let in_scope: Vec<&ActivatedNode> = activated - .iter() - .filter(|a| matches_filter(a, &schema.node_filter)) - .collect(); - - let nodes_in_scope = in_scope.len(); - - match schema.projection_type { - ProjectionType::Relational | ProjectionType::Document => { - let rows = in_scope - .iter() - .map(|a| project_row(a, &schema.field_mappings)) - .collect::>>()?; - - Ok(PResult { - schema_name: schema.name.clone(), - nodes_in_scope, - rows, - key_value: None, - wide_column: None, - }) - } - - ProjectionType::KeyValue => { - let mut kv: HashMap = HashMap::new(); - for a in &in_scope { - let key = a.node.id.to_string(); - let val = String::from_utf8_lossy(&a.node.content).to_string(); - kv.insert(key, Value::String(val)); - } - Ok(PResult { - schema_name: schema.name.clone(), - nodes_in_scope, - rows: vec![], - key_value: Some(kv), - wide_column: None, - }) - } - - ProjectionType::WideColumn => { - let mut wc: HashMap> = HashMap::new(); - for a in &in_scope { - let id = a.node.id.to_string(); - let mut cols = HashMap::new(); - for mapping in &schema.field_mappings { - let val = extract_field(a, &mapping.source) - .unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null)); - cols.insert(mapping.field_name.clone(), val); - } - wc.insert(id, cols); - } - Ok(PResult { - schema_name: schema.name.clone(), - nodes_in_scope, - rows: vec![], - key_value: None, - wide_column: Some(wc), - }) - } - } - } -} - -// ── Node filter evaluation ──────────────────────────────────────────────────── - -fn matches_filter(a: &ActivatedNode, filter: &NodeFilter) -> bool { - match filter { - NodeFilter::All => true, - - NodeFilter::ByType(types) => { - let node_type_str = node_type_str(&a.node.node_type); - types.iter().any(|t| t == &node_type_str) - } - - NodeFilter::ByTier(tiers) => tiers.iter().any(|t| t == &a.node.tier), - - NodeFilter::ByTag(tags) => { - // Tags are searched in content (treated as UTF-8) as a simple substring match. - // This is intentionally lenient — callers may embed tag metadata in content. - let content_str = String::from_utf8_lossy(&a.node.content); - tags.iter().any(|tag| content_str.contains(tag.as_str())) - } - - NodeFilter::ByActivationThreshold(threshold) => a.activation_strength >= *threshold, - - NodeFilter::BySalience(threshold) => a.node.salience >= *threshold, - - NodeFilter::Combined(filters) => filters.iter().all(|f| matches_filter(a, f)), - - NodeFilter::Any(filters) => filters.iter().any(|f| matches_filter(a, f)), - } -} - -// ── Row projection ──────────────────────────────────────────────────────────── - -fn project_row(a: &ActivatedNode, mappings: &[FieldMapping]) -> ProjectionResult { - let mut fields = HashMap::new(); - for mapping in mappings { - let val = extract_field(a, &mapping.source) - .unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null)); - fields.insert(mapping.field_name.clone(), val); - } - Ok(ProjectedRow { - node_id: a.node.id, - fields, - }) -} - -// ── Field extraction ────────────────────────────────────────────────────────── - -fn extract_field(a: &ActivatedNode, source: &FieldSource) -> Option { - match source { - FieldSource::NodeId => Some(Value::String(a.node.id.to_string())), - - FieldSource::NodeType => Some(Value::String(node_type_str(&a.node.node_type).to_string())), - - FieldSource::Tier => Some(Value::String(tier_str(&a.node.tier).to_string())), - - FieldSource::Salience => Some(json!(a.node.salience)), - - FieldSource::Importance => Some(json!(a.node.importance)), - - FieldSource::ActivationStrength => Some(json!(a.activation_strength)), - - FieldSource::Hops => Some(json!(a.hops)), - - FieldSource::CreatedAt => Some(json!(a.node.created_at)), - - FieldSource::LastActivated => Some(json!(a.node.last_activated)), - - FieldSource::ActivationCount => Some(json!(a.node.activation_count)), - - FieldSource::ContentRaw => { - Some(Value::String(String::from_utf8_lossy(&a.node.content).to_string())) - } - - FieldSource::ContentBase64 => Some(Value::String(B64.encode(&a.node.content))), - - FieldSource::ContentJsonPath(path) => { - // Parse content as JSON, then traverse the dot-path - let content_str = std::str::from_utf8(&a.node.content).ok()?; - let doc: Value = serde_json::from_str(content_str).ok()?; - traverse_json_path(&doc, path).cloned() - } - - FieldSource::Literal(v) => Some(v.clone()), - } -} - -/// Traverse a dot-separated JSON path. -/// E.g., "user.name" on `{"user": {"name": "Alice"}}` returns `"Alice"`. -fn traverse_json_path<'a>(doc: &'a Value, path: &str) -> Option<&'a Value> { - let mut current = doc; - for segment in path.split('.') { - current = match current { - Value::Object(map) => map.get(segment)?, - Value::Array(arr) => { - let idx: usize = segment.parse().ok()?; - arr.get(idx)? - } - _ => return None, - }; - } - Some(current) -} - -// ── String helpers ──────────────────────────────────────────────────────────── - -fn node_type_str(t: &NodeType) -> String { - match t { - NodeType::Memory => "Memory".to_string(), - NodeType::Concept => "Concept".to_string(), - NodeType::Event => "Event".to_string(), - NodeType::Entity => "Entity".to_string(), - NodeType::Process => "Process".to_string(), - NodeType::InternalState => "InternalState".to_string(), - NodeType::Custom(s) => s.clone(), - } -} - -fn tier_str(t: &engram_core::types::MemoryTier) -> &'static str { - use engram_core::types::MemoryTier; - match t { - MemoryTier::Working => "Working", - MemoryTier::Episodic => "Episodic", - MemoryTier::Semantic => "Semantic", - MemoryTier::Procedural => "Procedural", - } -} - -/// Extract node_id from a projected row (used for display / keying). -pub fn row_id(row: &ProjectedRow) -> Uuid { - row.node_id -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{FieldMapping, FieldSource, NodeFilter, ProjectionSchema, ProjectionType}; - use engram_core::types::{ActivatedNode, MemoryTier, Node, NodeType}; - - fn make_node(content: &str, tier: MemoryTier, importance: f32) -> Node { - Node::new( - NodeType::Memory, - vec![1.0, 0.0], - content.as_bytes().to_vec(), - tier, - importance, - ) - } - - fn make_activated(node: Node, strength: f32) -> ActivatedNode { - ActivatedNode { - node, - activation_strength: strength, - hops: 1, - } - } - - #[test] - fn test_relational_projection_basic() { - let node = make_node("hello world", MemoryTier::Semantic, 0.8); - let activated = vec![make_activated(node, 0.9)]; - - let schema = ProjectionSchema { - name: "test".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::All, - field_mappings: vec![ - FieldMapping { - field_name: "content".into(), - source: FieldSource::ContentRaw, - default: None, - }, - FieldMapping { - field_name: "tier".into(), - source: FieldSource::Tier, - default: None, - }, - FieldMapping { - field_name: "strength".into(), - source: FieldSource::ActivationStrength, - default: None, - }, - ], - }; - - let result = ProjectionEngine::project(&schema, &activated).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.rows[0].fields["content"], Value::String("hello world".into())); - assert_eq!(result.rows[0].fields["tier"], Value::String("Semantic".into())); - } - - #[test] - fn test_key_value_projection() { - let node = make_node("test content", MemoryTier::Working, 0.5); - let activated = vec![make_activated(node, 0.7)]; - - let schema = ProjectionSchema { - name: "kv".into(), - description: None, - projection_type: ProjectionType::KeyValue, - node_filter: NodeFilter::All, - field_mappings: vec![], - }; - - let result = ProjectionEngine::project(&schema, &activated).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - let kv = result.key_value.unwrap(); - assert_eq!(kv.len(), 1); - let content = kv.values().next().unwrap(); - assert_eq!(content, &Value::String("test content".into())); - } - - #[test] - fn test_filter_by_activation_threshold() { - let n1 = make_activated(make_node("high", MemoryTier::Semantic, 0.9), 0.8); - let n2 = make_activated(make_node("low", MemoryTier::Episodic, 0.3), 0.1); - - let schema = ProjectionSchema { - name: "filtered".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::ByActivationThreshold(0.5), - field_mappings: vec![FieldMapping { - field_name: "content".into(), - source: FieldSource::ContentRaw, - default: None, - }], - }; - - let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - assert_eq!(result.rows[0].fields["content"], Value::String("high".into())); - } - - #[test] - fn test_filter_by_tier() { - let n1 = make_activated(make_node("semantic", MemoryTier::Semantic, 0.9), 0.5); - let n2 = make_activated(make_node("working", MemoryTier::Working, 0.5), 0.5); - - let schema = ProjectionSchema { - name: "tier_filter".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::ByTier(vec![MemoryTier::Semantic]), - field_mappings: vec![FieldMapping { - field_name: "content".into(), - source: FieldSource::ContentRaw, - default: None, - }], - }; - - let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - assert_eq!(result.rows[0].fields["content"], Value::String("semantic".into())); - } - - #[test] - fn test_json_path_extraction() { - let content = r#"{"user": {"name": "Alice", "age": 30}}"#; - let node = make_node(content, MemoryTier::Semantic, 0.8); - let activated = vec![make_activated(node, 0.9)]; - - let schema = ProjectionSchema { - name: "json_path".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::All, - field_mappings: vec![ - FieldMapping { - field_name: "name".into(), - source: FieldSource::ContentJsonPath("user.name".into()), - default: None, - }, - FieldMapping { - field_name: "age".into(), - source: FieldSource::ContentJsonPath("user.age".into()), - default: None, - }, - ], - }; - - let result = ProjectionEngine::project(&schema, &activated).unwrap(); - assert_eq!(result.rows[0].fields["name"], Value::String("Alice".into())); - assert_eq!(result.rows[0].fields["age"], json!(30)); - } - - #[test] - fn test_wide_column_projection() { - let n1 = make_activated(make_node("col_content", MemoryTier::Procedural, 0.6), 0.5); - - let schema = ProjectionSchema { - name: "wide".into(), - description: None, - projection_type: ProjectionType::WideColumn, - node_filter: NodeFilter::All, - field_mappings: vec![ - FieldMapping { - field_name: "raw".into(), - source: FieldSource::ContentRaw, - default: None, - }, - FieldMapping { - field_name: "tier".into(), - source: FieldSource::Tier, - default: None, - }, - ], - }; - - let result = ProjectionEngine::project(&schema, &[n1]).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - let wc = result.wide_column.unwrap(); - assert_eq!(wc.len(), 1); - let cols = wc.values().next().unwrap(); - assert_eq!(cols["raw"], Value::String("col_content".into())); - assert_eq!(cols["tier"], Value::String("Procedural".into())); - } - - #[test] - fn test_combined_filter() { - let n1 = make_activated(make_node("tag:important semantic", MemoryTier::Semantic, 0.9), 0.8); - let n2 = make_activated(make_node("no tag", MemoryTier::Semantic, 0.9), 0.8); - let n3 = make_activated(make_node("tag:important working", MemoryTier::Working, 0.3), 0.8); - - let filter = NodeFilter::Combined(vec![ - NodeFilter::ByTier(vec![MemoryTier::Semantic]), - NodeFilter::ByTag(vec!["tag:important".into()]), - ]); - - let schema = ProjectionSchema { - name: "combined".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: filter, - field_mappings: vec![FieldMapping { - field_name: "content".into(), - source: FieldSource::ContentRaw, - default: None, - }], - }; - - let result = ProjectionEngine::project(&schema, &[n1, n2, n3]).unwrap(); - assert_eq!(result.nodes_in_scope, 1); - assert_eq!( - result.rows[0].fields["content"], - Value::String("tag:important semantic".into()) - ); - } - - #[test] - fn test_literal_field() { - let node = make_node("any", MemoryTier::Working, 0.5); - let activated = vec![make_activated(node, 0.5)]; - - let schema = ProjectionSchema { - name: "literal".into(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::All, - field_mappings: vec![FieldMapping { - field_name: "schema_version".into(), - source: FieldSource::Literal(json!("v1")), - default: None, - }], - }; - - let result = ProjectionEngine::project(&schema, &activated).unwrap(); - assert_eq!(result.rows[0].fields["schema_version"], Value::String("v1".into())); - } -} diff --git a/engrams/engram-projection/src/error.rs b/engrams/engram-projection/src/error.rs deleted file mode 100644 index 7527ac7..0000000 --- a/engrams/engram-projection/src/error.rs +++ /dev/null @@ -1,24 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ProjectionError { - #[error("Projection not found: {0}")] - NotFound(String), - - #[error("Projection already exists: {0}")] - AlreadyExists(String), - - #[error("Field mapping error: {0}")] - FieldMapping(String), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Engram error: {0}")] - Engram(#[from] engram_core::EngramError), - - #[error("Invalid projection schema: {0}")] - InvalidSchema(String), -} - -pub type ProjectionResult = Result; diff --git a/engrams/engram-projection/src/lib.rs b/engrams/engram-projection/src/lib.rs deleted file mode 100644 index a9d311b..0000000 --- a/engrams/engram-projection/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -/// Engram Projection Layer — schema-as-a-view over the activation surface. -/// -/// # The Core Insight -/// -/// Engram has no schema. A node has: embedding (semantic identity), content -/// (arbitrary bytes), metadata via tier/type, and salience. Schema is a -/// *projection* — a view imposed on the activation surface at query time. -/// -/// The same Engram graph can surface as relational rows, JSON documents, -/// wide-column families, or key-value pairs depending on how you project it. -/// Migrations are free because there is nothing to migrate — you just update -/// the projection. -/// -/// # How It Works -/// -/// 1. Register a `ProjectionSchema` that describes which nodes are in scope -/// and how to map their fields. -/// 2. At query time, run spreading activation (or use an existing result set). -/// 3. Apply the projection to map `ActivatedNode`s into the projected view. -/// -/// The projection is purely a read-time transform. It never modifies the graph. -pub mod engine; -pub mod error; -pub mod registry; -pub mod schema; - -pub use engine::ProjectionEngine; -pub use error::ProjectionError; -pub use registry::ProjectionRegistry; -pub use schema::{ - FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult, ProjectionSchema, - ProjectionType, -}; diff --git a/engrams/engram-projection/src/registry.rs b/engrams/engram-projection/src/registry.rs deleted file mode 100644 index 95c1c78..0000000 --- a/engrams/engram-projection/src/registry.rs +++ /dev/null @@ -1,133 +0,0 @@ -/// In-memory registry of named projection schemas. -/// -/// The registry is the store of all registered projections. In a running server, -/// one registry instance is shared (behind a Mutex or RwLock). Projections are -/// looked up by name to execute queries. -use std::collections::HashMap; - -use crate::error::{ProjectionError, ProjectionResult}; -use crate::schema::ProjectionSchema; - -/// Holds all registered `ProjectionSchema`s, keyed by name. -#[derive(Default)] -pub struct ProjectionRegistry { - schemas: HashMap, -} - -impl ProjectionRegistry { - pub fn new() -> Self { - Self { - schemas: HashMap::new(), - } - } - - /// Register a new schema. Fails if one with the same name already exists. - pub fn register(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> { - if self.schemas.contains_key(&schema.name) { - return Err(ProjectionError::AlreadyExists(schema.name.clone())); - } - if schema.name.is_empty() { - return Err(ProjectionError::InvalidSchema("name must not be empty".into())); - } - self.schemas.insert(schema.name.clone(), schema); - Ok(()) - } - - /// Replace an existing schema (upsert). Creates if not present. - pub fn upsert(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> { - if schema.name.is_empty() { - return Err(ProjectionError::InvalidSchema("name must not be empty".into())); - } - self.schemas.insert(schema.name.clone(), schema); - Ok(()) - } - - /// Retrieve a schema by name. - pub fn get(&self, name: &str) -> ProjectionResult<&ProjectionSchema> { - self.schemas - .get(name) - .ok_or_else(|| ProjectionError::NotFound(name.to_string())) - } - - /// List all schema names. - pub fn list(&self) -> Vec<&ProjectionSchema> { - let mut schemas: Vec<&ProjectionSchema> = self.schemas.values().collect(); - schemas.sort_by(|a, b| a.name.cmp(&b.name)); - schemas - } - - /// Remove a schema by name. Returns true if it existed. - pub fn remove(&mut self, name: &str) -> bool { - self.schemas.remove(name).is_some() - } - - /// Number of registered schemas. - pub fn len(&self) -> usize { - self.schemas.len() - } - - /// True if no schemas are registered. - pub fn is_empty(&self) -> bool { - self.schemas.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{NodeFilter, ProjectionType}; - - fn make_schema(name: &str) -> ProjectionSchema { - ProjectionSchema { - name: name.to_string(), - description: None, - projection_type: ProjectionType::Relational, - node_filter: NodeFilter::All, - field_mappings: vec![], - } - } - - #[test] - fn test_register_and_get() { - let mut reg = ProjectionRegistry::new(); - reg.register(make_schema("users")).unwrap(); - let s = reg.get("users").unwrap(); - assert_eq!(s.name, "users"); - } - - #[test] - fn test_register_duplicate_fails() { - let mut reg = ProjectionRegistry::new(); - reg.register(make_schema("events")).unwrap(); - assert!(reg.register(make_schema("events")).is_err()); - } - - #[test] - fn test_upsert_replaces() { - let mut reg = ProjectionRegistry::new(); - reg.register(make_schema("s1")).unwrap(); - let mut updated = make_schema("s1"); - updated.description = Some("updated".into()); - reg.upsert(updated).unwrap(); - assert_eq!(reg.get("s1").unwrap().description.as_deref(), Some("updated")); - } - - #[test] - fn test_list_sorted() { - let mut reg = ProjectionRegistry::new(); - reg.register(make_schema("zoo")).unwrap(); - reg.register(make_schema("alpha")).unwrap(); - reg.register(make_schema("mango")).unwrap(); - let names: Vec<&str> = reg.list().iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "mango", "zoo"]); - } - - #[test] - fn test_remove() { - let mut reg = ProjectionRegistry::new(); - reg.register(make_schema("temp")).unwrap(); - assert!(reg.remove("temp")); - assert!(!reg.remove("temp")); - assert!(reg.get("temp").is_err()); - } -} diff --git a/engrams/engram-projection/src/schema.rs b/engrams/engram-projection/src/schema.rs deleted file mode 100644 index 79e7543..0000000 --- a/engrams/engram-projection/src/schema.rs +++ /dev/null @@ -1,133 +0,0 @@ -/// Schema types for the projection layer. -/// -/// A `ProjectionSchema` defines a named view over the Engram graph. -/// It specifies which nodes are in scope and how to extract fields from them. -use engram_core::types::MemoryTier; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -/// How the projection presents data to the caller. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ProjectionType { - /// Nodes as rows; edges become foreign key references. - /// Each row is a flat map of field_name → value. - Relational, - /// Nodes as JSON documents. - /// Fields are nested under a "fields" key; metadata at the top level. - Document, - /// Nodes as column families (node_id → column_name → value). - /// Suitable for wide, sparse schemas. - WideColumn, - /// Simple node_id → content mapping. - /// Ignores field mappings; raw content bytes as base64. - KeyValue, -} - -/// Which nodes from the activation result set fall within this projection's scope. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "value")] -pub enum NodeFilter { - /// Include nodes whose node_type matches any of the given strings. - ByType(Vec), - /// Include nodes in any of the given memory tiers. - ByTier(Vec), - /// Include nodes whose tier name contains any of the given tag strings - /// (stored in node metadata via content prefix convention). - ByTag(Vec), - /// Include nodes with activation strength >= threshold. - ByActivationThreshold(f32), - /// Include nodes whose salience >= threshold. - BySalience(f32), - /// All of the sub-filters must match (AND). - Combined(Vec), - /// Any sub-filter matches (OR). - Any(Vec), - /// Pass all nodes through without filtering. - All, -} - -/// Where to source a projected field's value. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "value")] -pub enum FieldSource { - /// Extract a value from the node's content, interpreted as JSON, using a dot-path. - /// E.g., "user.name" extracts `{ "user": { "name": "Alice" } }["user"]["name"]`. - ContentJsonPath(String), - /// The node's content, raw, as a UTF-8 string (lossy). - ContentRaw, - /// The node's content as a base64-encoded string. - ContentBase64, - /// The node's unique identifier. - NodeId, - /// The node's type as a string. - NodeType, - /// The node's memory tier as a string. - Tier, - /// The node's current salience score. - Salience, - /// The node's importance (caller-set, stable). - Importance, - /// The activation strength at this node (from spreading activation). - ActivationStrength, - /// The hop count from the nearest seed node. - Hops, - /// The node's creation timestamp (Unix ms). - CreatedAt, - /// The node's last-activated timestamp (Unix ms). - LastActivated, - /// The node's activation count. - ActivationCount, - /// A literal constant value. - Literal(Value), -} - -/// One field in a projected row. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FieldMapping { - /// The name of this field in the projected output. - pub field_name: String, - /// Where to get the value from. - pub source: FieldSource, - /// If the source fails to produce a value, use this fallback. Null means omit. - pub default: Option, -} - -/// A complete schema definition. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectionSchema { - /// Unique name for this projection. - pub name: String, - /// Human-readable description. - pub description: Option, - /// How results are shaped. - pub projection_type: ProjectionType, - /// Which nodes from the activation result are included. - pub node_filter: NodeFilter, - /// How to extract fields from each included node. - pub field_mappings: Vec, -} - -/// One projected node in a Relational or Document projection. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectedRow { - /// The source node's UUID (always included). - pub node_id: uuid::Uuid, - /// Extracted fields as ordered map field_name → value. - pub fields: HashMap, -} - -/// The output of running a projection over an activation result set. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectionResult { - /// The schema that produced this result. - pub schema_name: String, - /// How many nodes from the activation set were in scope. - pub nodes_in_scope: usize, - /// The projected rows. - pub rows: Vec, - /// For KeyValue projection: node_id (string) → content. - pub key_value: Option>, - /// For WideColumn projection: node_id → column_name → value. - pub wide_column: Option>>, -} diff --git a/engrams/engram-reasoning/Cargo.toml b/engrams/engram-reasoning/Cargo.toml deleted file mode 100644 index 7964427..0000000 --- a/engrams/engram-reasoning/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "engram-reasoning" -version = "0.1.0" -edition = "2021" -description = "Graph-native inference engine for Engram — evidence chains, confidence propagation, causal reasoning" -license = "MIT" - -[dependencies] -engram-core = { path = "../engram-core" } -uuid = { version = "1", features = ["v4", "serde"] } -serde = { version = "1", features = ["derive"] } -anyhow = "1" -thiserror = "1" - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-reasoning/src/engine.rs b/engrams/engram-reasoning/src/engine.rs deleted file mode 100644 index 529a752..0000000 --- a/engrams/engram-reasoning/src/engine.rs +++ /dev/null @@ -1,1013 +0,0 @@ -/// ReasoningEngine — graph-native inference over the Engram knowledge graph. -/// -/// # The Core Insight -/// -/// Language models conflate reasoning and generation: transformer weights encode -/// both the inference logic and the ability to verbalize conclusions. You cannot -/// separate them — the same matrix multiplication does both. -/// -/// This engine separates them deliberately: -/// -/// 1. **Reasoning** — `ReasoningEngine::reason()` traverses the knowledge graph -/// via spreading activation, classifies activated nodes as evidence, builds -/// typed inference chains, and computes a confidence-weighted verdict. -/// No language model is involved. The reasoning IS the graph traversal. -/// -/// 2. **Generation** — A separate codec (not in this crate) converts the -/// `ReasoningResult` into natural language. It renders the evidence chains -/// and verdict into prose; it does not alter the logical content. -/// -/// The verdict is determined by the graph structure, not by which tokens were -/// sampled. This is what makes it "not an LLM." -/// -/// # Algorithm -/// -/// 1. Embed the hypothesis text (caller provides embedding) -/// 2. Find seed nodes via vector similarity search -/// 3. Run spreading activation from seeds (EngramDb::activate) -/// 4. Classify each activated node as evidence (support/refute/context) -/// 5. Build evidence chains by following typed edges through activated subgraph -/// 6. Propagate confidence through chains -/// 7. Compute verdict from support vs. refutation mass -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; - -use engram_core::{EngramDb, EngramResult}; -use engram_core::types::{Node, NodeType}; -use uuid::Uuid; - -use crate::types::{ - CausalDirection, ChainType, Conclusion, EvidenceChain, EvidenceNode, EvidenceType, - Hypothesis, HypothesisType, InferenceEdge, InferenceEdgeType, ReasoningConfig, - ReasoningResult, Verdict, -}; - -// ── Cosine similarity (inlined — no dep on private engram_core::vector) ─────── - -/// Cosine similarity between two embedding vectors, clamped to [0.0, 1.0]. -fn cosine_sim(a: &[f32], b: &[f32]) -> f32 { - if a.is_empty() || b.is_empty() || a.len() != b.len() { - 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(0.0, 1.0) -} - -/// Simple negation detection: does the content contain negation markers -/// near keywords from the hypothesis? -fn has_negation_signals(content: &str) -> bool { - let lower = content.to_lowercase(); - let negation_words = ["not", "never", "no ", "false", "incorrect", "wrong", - "cannot", "can't", "doesn't", "isn't", "aren't", - "wasn't", "weren't", "won't", "wouldn't", "shouldn't", - "couldn't", "invalid", "disproves", "refutes", "contra"]; - negation_words.iter().any(|w| lower.contains(w)) -} - -// ── ReasoningEngine ─────────────────────────────────────────────────────────── - -pub struct ReasoningEngine { - db: Arc>, - pub config: ReasoningConfig, -} - -impl ReasoningEngine { - pub fn new(db: Arc>, config: ReasoningConfig) -> Self { - Self { db, config } - } - - pub fn with_default_config(db: Arc>) -> Self { - Self::new(db, ReasoningConfig::default()) - } - - // ── Core reasoning pass ─────────────────────────────────────────────────── - - /// Evaluate a hypothesis against the knowledge graph. - /// - /// Returns a full `ReasoningResult` including verdict, evidence chains, - /// and confidence scores. This is the primary entry point. - pub fn reason(&mut self, hypothesis: &Hypothesis) -> EngramResult { - let mut reasoning_steps = 0u32; - - // Step 1: Find seed nodes via vector search - let seeds: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - let scored = db.search_embedding( - &hypothesis.embedding, - 10.min(self.config.max_evidence_nodes as usize), - )?; - reasoning_steps += 1; - scored.into_iter().map(|s| s.node.id).collect() - }; - - if seeds.is_empty() { - // Graph is empty — cannot reason - return Ok(ReasoningResult { - hypothesis: hypothesis.clone(), - conclusion: Conclusion { - verdict: Verdict::Insufficient, - summary: "No relevant nodes found in the knowledge graph.".into(), - confidence: 0.0, - primary_evidence: vec![], - }, - evidence_chains: vec![], - confidence: 0.0, - reasoning_steps, - nodes_visited: 0, - }); - } - - // Step 2: Spreading activation from seeds - let activated: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.activate( - &seeds, - &hypothesis.embedding, - self.config.max_depth.min(u8::MAX as u32) as u8, - self.config.max_evidence_nodes as usize, - )? - }; - reasoning_steps += 1; - let nodes_visited = activated.len() as u32 + seeds.len() as u32; - - // Step 3: Classify each activated node as evidence - let mut evidence_nodes: Vec = activated - .iter() - .filter(|a| a.activation_strength >= self.config.min_confidence) - .map(|a| self.classify_evidence(&a.node, hypothesis, a.activation_strength, a.hops)) - .collect(); - - // Also include the seed nodes themselves as evidence - { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - for seed_id in &seeds { - if let Some(node) = db.get_node(*seed_id)? { - let ev = self.classify_evidence(&node, hypothesis, 1.0, 0); - evidence_nodes.push(ev); - } - } - } - reasoning_steps += evidence_nodes.len() as u32; - - // Step 4: Build inference edges from the activated subgraph - let activated_ids: HashSet = evidence_nodes - .iter() - .map(|e| e.engram_node_id) - .collect(); - - let inference_edges = self.build_inference_edges(&activated_ids, hypothesis)?; - reasoning_steps += 1; - - // Step 5: Propagate confidence through nodes - self.propagate_confidence(&mut evidence_nodes, &inference_edges); - reasoning_steps += 1; - - // Step 6: Build evidence chains - let evidence_chains = self.build_chains(&evidence_nodes, &inference_edges, hypothesis); - reasoning_steps += 1; - - // Step 7: Compute verdict - let (verdict, confidence) = self.compute_verdict(&evidence_nodes, hypothesis); - reasoning_steps += 1; - - // Collect primary evidence (strongest items for/against) - let mut primary_evidence: Vec = evidence_nodes - .iter() - .filter(|e| { - matches!( - e.evidence_type, - EvidenceType::DirectSupport - | EvidenceType::DirectRefutation - | EvidenceType::IndirectSupport - | EvidenceType::IndirectRefutation - ) - }) - .cloned() - .collect(); - primary_evidence.sort_by(|a, b| { - b.confidence - .partial_cmp(&a.confidence) - .unwrap_or(std::cmp::Ordering::Equal) - }); - primary_evidence.truncate(5); - - let summary = self.build_summary(&verdict, &primary_evidence, hypothesis); - - Ok(ReasoningResult { - hypothesis: hypothesis.clone(), - conclusion: Conclusion { - verdict, - summary, - confidence, - primary_evidence, - }, - evidence_chains, - confidence, - reasoning_steps, - nodes_visited, - }) - } - - // ── Causal chain ────────────────────────────────────────────────────────── - - /// Find causal chains: what causes a concept, or what a concept causes. - /// - /// Traverses the graph following `"causes"` edges in the - /// requested direction. - pub fn causal_chain( - &mut self, - concept_embedding: &[f32], - direction: CausalDirection, - ) -> EngramResult> { - // Find seed nodes close to the concept - let seeds: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.search_embedding(concept_embedding, 5)? - .into_iter() - .map(|s| s.node) - .collect() - }; - - if seeds.is_empty() { - return Ok(vec![]); - } - - let mut chains: Vec = Vec::new(); - - for seed in &seeds { - let traversal_nodes: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.traverse(seed.id, Some("causes"), self.config.max_depth as u8)? - }; - - // Build evidence nodes — always start with the seed as the first node - let seed_sim = cosine_sim(concept_embedding, &seed.embedding); - let seed_ev = EvidenceNode { - engram_node_id: seed.id, - content: String::from_utf8_lossy(&seed.content).into_owned(), - evidence_type: EvidenceType::CausalAntecedent, - confidence: (seed.importance * seed.salience.clamp(0.0, 1.0)).clamp(0.0, 1.0), - activation_strength: seed_sim, - hops_from_seed: 0, - }; - - let mut ev_nodes: Vec = vec![seed_ev]; - - for (i, node) in traversal_nodes.iter().enumerate() { - let sim = cosine_sim(concept_embedding, &node.embedding); - let ev_type = match direction { - CausalDirection::Backward => EvidenceType::CausalAntecedent, - CausalDirection::Forward | CausalDirection::Both => { - EvidenceType::CausalConsequent - } - }; - ev_nodes.push(EvidenceNode { - engram_node_id: node.id, - content: String::from_utf8_lossy(&node.content).into_owned(), - evidence_type: ev_type, - confidence: (node.importance * node.salience.clamp(0.0, 1.0)).clamp(0.0, 1.0), - activation_strength: sim, - hops_from_seed: (i + 1) as u32, - }); - } - - // Need at least two nodes to form a chain - if ev_nodes.len() < 2 { - continue; - } - - // Build inference edges along the chain - let ev_edges: Vec = ev_nodes - .windows(2) - .map(|w| InferenceEdge { - from_node: w[0].engram_node_id, - to_node: w[1].engram_node_id, - edge_type: InferenceEdgeType::Causes, - strength: (w[0].confidence + w[1].confidence) / 2.0, - engram_edge_id: None, - }) - .collect(); - - let chain_confidence = EvidenceChain::compute_confidence(&ev_edges); - - chains.push(EvidenceChain { - nodes: ev_nodes, - edges: ev_edges, - chain_confidence, - chain_type: ChainType::CausalChain, - }); - } - - // Sort by chain confidence descending - chains.sort_by(|a, b| { - b.chain_confidence - .partial_cmp(&a.chain_confidence) - .unwrap_or(std::cmp::Ordering::Equal) - }); - Ok(chains) - } - - // ── Procedural chain ────────────────────────────────────────────────────── - - /// Find ordered steps for a HowTo query. - /// - /// Traverses `"causes"` and `"contains"` edges - /// from Process/Procedural nodes matching the goal embedding. - pub fn procedural_chain(&mut self, goal_embedding: &[f32]) -> EngramResult> { - let process_nodes: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - // Search for nodes relevant to the goal - let scored = db.search_embedding(goal_embedding, 10)?; - scored - .into_iter() - .filter(|s| { - matches!(s.node.node_type, NodeType::Process) - && s.score > 0.3 - }) - .map(|s| s.node) - .collect() - }; - - if process_nodes.is_empty() { - // Fallback: use any activated nodes sorted by hop/salience - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - let scored = db.search_embedding(goal_embedding, 5)?; - return Ok(scored - .into_iter() - .map(|s| String::from_utf8_lossy(&s.node.content).into_owned()) - .collect()); - } - - // Follow the process chain from the best matching node - let best_process = &process_nodes[0]; - let steps: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.traverse(best_process.id, Some("causes"), self.config.max_depth as u8)? - }; - - let mut ordered_steps: Vec = Vec::new(); - // The best process node itself is step 0 - ordered_steps.push(String::from_utf8_lossy(&best_process.content).into_owned()); - for node in steps { - ordered_steps.push(String::from_utf8_lossy(&node.content).into_owned()); - } - Ok(ordered_steps) - } - - // ── Contradiction detection ─────────────────────────────────────────────── - - /// Find pairs of nodes in the graph that contradict each other relative - /// to the given topic embedding. - /// - /// Returns pairs `(supporting_node, refuting_node)` where both nodes are - /// activated by the topic, but one has negation signals and the other does not. - pub fn find_contradictions( - &mut self, - topic_embedding: &[f32], - ) -> EngramResult> { - // Activate the graph around the topic - let seeds: Vec = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.search_embedding(topic_embedding, 10)? - .into_iter() - .map(|s| s.node.id) - .collect() - }; - - if seeds.is_empty() { - return Ok(vec![]); - } - - let activated = { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - db.activate(&seeds, topic_embedding, 3, 30)? - }; - - // Check explicit Contradicts edges from both seed nodes and activated nodes - let mut contradicts_pairs: Vec<(EvidenceNode, EvidenceNode)> = Vec::new(); - { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - - // Build combined candidate list: seed nodes + activated nodes - let seed_nodes: Vec<(Uuid, f32, u8)> = seeds - .iter() - .filter_map(|&id| { - db.get_node(id).ok().flatten().map(|n| { - let sim = cosine_sim(topic_embedding, &n.embedding); - (id, sim, 0u8) - }) - }) - .collect(); - - let all_candidates: Vec<(Uuid, f32, u8)> = seed_nodes - .into_iter() - .chain(activated.iter().map(|an| { - (an.node.id, an.activation_strength, an.hops) - })) - .collect(); - - for (node_id, activation_strength, hops) in &all_candidates { - if let Some(from_node) = db.get_node(*node_id)? { - let edges = db.get_edges_from(*node_id)?; - for edge in edges { - if edge.relation == "contradicts" { - if let Some(target) = db.get_node(edge.to_id)? { - let sim_a = cosine_sim(topic_embedding, &from_node.embedding); - let sim_b = cosine_sim(topic_embedding, &target.embedding); - if sim_a > 0.3 && sim_b > 0.3 { - let ev_a = EvidenceNode { - engram_node_id: from_node.id, - content: String::from_utf8_lossy(&from_node.content) - .into_owned(), - evidence_type: EvidenceType::DirectSupport, - confidence: from_node.importance.clamp(0.0, 1.0), - activation_strength: *activation_strength, - hops_from_seed: *hops as u32, - }; - let ev_b = EvidenceNode { - engram_node_id: target.id, - content: String::from_utf8_lossy(&target.content) - .into_owned(), - evidence_type: EvidenceType::DirectRefutation, - confidence: target.importance.clamp(0.0, 1.0), - activation_strength: sim_b, - hops_from_seed: *hops as u32 + 1, - }; - contradicts_pairs.push((ev_a, ev_b)); - } - } - } - } - } - } - } - - // Also find pairs where one node has negation signals and shares high - // semantic similarity with another node that does not - let mut support_nodes: Vec<&engram_core::types::ActivatedNode> = Vec::new(); - let mut refutation_nodes: Vec<&engram_core::types::ActivatedNode> = Vec::new(); - - for an in &activated { - let sim = cosine_sim(topic_embedding, &an.node.embedding); - if sim < 0.4 { - continue; - } - let content = String::from_utf8_lossy(&an.node.content); - if has_negation_signals(&content) { - refutation_nodes.push(an); - } else { - support_nodes.push(an); - } - } - - for sup in &support_nodes { - for ref_node in &refutation_nodes { - let mutual_sim = cosine_sim(&sup.node.embedding, &ref_node.node.embedding); - if mutual_sim > 0.6 { - // These two nodes are about the same thing but one negates - let ev_sup = EvidenceNode { - engram_node_id: sup.node.id, - content: String::from_utf8_lossy(&sup.node.content).into_owned(), - evidence_type: EvidenceType::DirectSupport, - confidence: sup.node.importance.clamp(0.0, 1.0), - activation_strength: sup.activation_strength, - hops_from_seed: sup.hops as u32, - }; - let ev_ref = EvidenceNode { - engram_node_id: ref_node.node.id, - content: String::from_utf8_lossy(&ref_node.node.content).into_owned(), - evidence_type: EvidenceType::DirectRefutation, - confidence: ref_node.node.importance.clamp(0.0, 1.0), - activation_strength: ref_node.activation_strength, - hops_from_seed: ref_node.hops as u32, - }; - // Avoid duplicates from the Contradicts edge scan - let already = contradicts_pairs.iter().any(|(a, b)| { - a.engram_node_id == ev_sup.engram_node_id - && b.engram_node_id == ev_ref.engram_node_id - }); - if !already { - contradicts_pairs.push((ev_sup, ev_ref)); - } - } - } - } - - Ok(contradicts_pairs) - } - - // ── Internal helpers ────────────────────────────────────────────────────── - - /// Classify an activated Engram node as an evidence node relative to the hypothesis. - pub(crate) fn classify_evidence( - &self, - node: &Node, - hypothesis: &Hypothesis, - activation_strength: f32, - hops: u8, - ) -> EvidenceNode { - let content = String::from_utf8_lossy(&node.content).into_owned(); - let sim = cosine_sim(&hypothesis.embedding, &node.embedding); - let negation = has_negation_signals(&content); - - let evidence_type = self.classify_evidence_type(node, hypothesis, sim, negation); - let confidence = self.compute_node_confidence(node, sim, activation_strength); - - EvidenceNode { - engram_node_id: node.id, - content, - evidence_type, - confidence, - activation_strength, - hops_from_seed: hops as u32, - } - } - - fn classify_evidence_type( - &self, - node: &Node, - hypothesis: &Hypothesis, - sim: f32, - negation: bool, - ) -> EvidenceType { - // Process nodes → procedural steps for HowTo queries - if node.node_type == NodeType::Process - && hypothesis.hypothesis_type == HypothesisType::HowTo - { - return EvidenceType::ProceduralStep; - } - - // High similarity — direct evidence - if sim > 0.8 { - if negation { - return EvidenceType::DirectRefutation; - } else { - return EvidenceType::DirectSupport; - } - } - - // Medium similarity — indirect evidence - if sim >= 0.5 { - if negation { - return EvidenceType::IndirectRefutation; - } else { - return EvidenceType::IndirectSupport; - } - } - - // Below threshold — contextual - EvidenceType::ContextualFact - } - - fn compute_node_confidence(&self, node: &Node, sim: f32, activation_strength: f32) -> f32 { - // Blend: semantic relevance × node importance × capped salience × activation - let salience_factor = node.salience.clamp(0.0, 1.0); - (sim * node.importance * salience_factor * activation_strength).clamp(0.0, 1.0) - } - - /// Build inference edges between activated nodes using stored Engram edges. - fn build_inference_edges( - &self, - activated_ids: &HashSet, - _hypothesis: &Hypothesis, - ) -> EngramResult> { - let db = self.db.lock().map_err(|_| { - engram_core::EngramError::InvalidParam("db lock poisoned".into()) - })?; - - let mut edges: Vec = Vec::new(); - let mut seen: HashSet<(Uuid, Uuid)> = HashSet::new(); - - for &node_id in activated_ids { - let engram_edges = db.get_edges_from(node_id)?; - for ee in engram_edges { - if !activated_ids.contains(&ee.to_id) { - continue; - } - let pair = (ee.from_id, ee.to_id); - if seen.contains(&pair) { - continue; - } - seen.insert(pair); - - let edge_type = relation_to_inference_edge(&ee.relation); - edges.push(InferenceEdge { - from_node: ee.from_id, - to_node: ee.to_id, - edge_type, - strength: ee.weight, - engram_edge_id: Some(ee.id), - }); - } - } - Ok(edges) - } - - /// Propagate confidence through the evidence graph via inference edges. - /// - /// For each node, find all incoming edges from other evidence nodes and - /// blend in the confidence carried by those edges. This models how a strong - /// chain of reasoning can increase confidence in downstream nodes even if - /// those nodes have weak intrinsic importance. - pub(crate) fn propagate_confidence( - &self, - nodes: &mut Vec, - edges: &[InferenceEdge], - ) { - // Build a map: to_node → [(from_node, strength, edge_type)] - let mut incoming: HashMap> = HashMap::new(); - for edge in edges { - incoming - .entry(edge.to_node) - .or_default() - .push((edge.from_node, edge.strength, &edge.edge_type)); - } - - // Build lookup for quick confidence retrieval - let conf_map: HashMap = nodes - .iter() - .map(|n| (n.engram_node_id, n.confidence)) - .collect(); - - // Apply one pass of confidence propagation - for node in nodes.iter_mut() { - if let Some(incomers) = incoming.get(&node.engram_node_id) { - let mut boost = 0.0f32; - for (from_id, strength, edge_type) in incomers { - if let Some(&from_conf) = conf_map.get(from_id) { - // Supportive edges boost confidence; refuting edges reduce it - let signed_boost = match edge_type { - InferenceEdgeType::Supports - | InferenceEdgeType::Implies - | InferenceEdgeType::Causes => from_conf * strength * 0.3, - InferenceEdgeType::Refutes | InferenceEdgeType::Contradicts => { - -(from_conf * strength * 0.3) - } - _ => from_conf * strength * 0.1, - }; - boost += signed_boost; - } - } - node.confidence = (node.confidence + boost).clamp(0.0, 1.0); - } - } - } - - /// Build evidence chains from the classified evidence nodes and inference edges. - fn build_chains( - &self, - nodes: &[EvidenceNode], - edges: &[InferenceEdge], - hypothesis: &Hypothesis, - ) -> Vec { - let mut chains: Vec = Vec::new(); - - // Build adjacency map for chain construction - let mut adj: HashMap> = HashMap::new(); - for edge in edges { - adj.entry(edge.from_node).or_default().push(edge); - } - - let node_map: HashMap = - nodes.iter().map(|n| (n.engram_node_id, n)).collect(); - - // Support chain: follow Supports/Implies edges from direct support nodes - let support_starts: Vec = nodes - .iter() - .filter(|n| n.evidence_type == EvidenceType::DirectSupport && n.hops_from_seed == 0) - .map(|n| n.engram_node_id) - .collect(); - - for start in support_starts { - if let Some(chain) = self.trace_chain( - start, - &adj, - &node_map, - ChainType::SupportChain, - 5, - hypothesis, - ) { - if chain.nodes.len() > 1 { - chains.push(chain); - } - } - } - - // Refutation chain: follow Refutes/Contradicts edges from direct refutation nodes - let refutation_starts: Vec = nodes - .iter() - .filter(|n| { - n.evidence_type == EvidenceType::DirectRefutation && n.hops_from_seed == 0 - }) - .map(|n| n.engram_node_id) - .collect(); - - for start in refutation_starts { - if let Some(chain) = self.trace_chain( - start, - &adj, - &node_map, - ChainType::RefutationChain, - 5, - hypothesis, - ) { - if chain.nodes.len() > 1 { - chains.push(chain); - } - } - } - - // Causal chain: follow Causes edges - let causal_starts: Vec = nodes - .iter() - .filter(|n| n.evidence_type == EvidenceType::CausalAntecedent) - .map(|n| n.engram_node_id) - .collect(); - - for start in causal_starts { - if let Some(chain) = self.trace_chain( - start, - &adj, - &node_map, - ChainType::CausalChain, - 5, - hypothesis, - ) { - if chain.nodes.len() > 1 { - chains.push(chain); - } - } - } - - // Process chain: follow edges from procedural step nodes - if hypothesis.hypothesis_type == HypothesisType::HowTo { - let process_starts: Vec = nodes - .iter() - .filter(|n| n.evidence_type == EvidenceType::ProceduralStep) - .map(|n| n.engram_node_id) - .collect(); - - for start in process_starts { - if let Some(chain) = self.trace_chain( - start, - &adj, - &node_map, - ChainType::ProcessChain, - 8, - hypothesis, - ) { - if chain.nodes.len() > 1 { - chains.push(chain); - } - } - } - } - - // Sort by chain confidence - chains.sort_by(|a, b| { - b.chain_confidence - .partial_cmp(&a.chain_confidence) - .unwrap_or(std::cmp::Ordering::Equal) - }); - chains - } - - /// DFS trace from a start node, following edges appropriate for the chain type. - fn trace_chain( - &self, - start: Uuid, - adj: &HashMap>, - node_map: &HashMap, - chain_type: ChainType, - max_len: usize, - _hypothesis: &Hypothesis, - ) -> Option { - let start_node = node_map.get(&start)?; - let mut chain_nodes: Vec = vec![(*start_node).clone()]; - let mut chain_edges: Vec = Vec::new(); - let mut visited: HashSet = HashSet::from([start]); - let mut current = start; - - for _ in 0..max_len { - let Some(outgoing) = adj.get(¤t) else { - break; - }; - - // Find the best edge for this chain type - let best_edge = outgoing.iter().filter(|e| { - !visited.contains(&e.to_node) - && edge_fits_chain_type(&e.edge_type, &chain_type) - }).max_by(|a, b| { - a.strength - .partial_cmp(&b.strength) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - let Some(edge) = best_edge else { - break; - }; - - let next = edge.to_node; - let Some(next_node) = node_map.get(&next) else { - break; - }; - - visited.insert(next); - chain_nodes.push((*next_node).clone()); - chain_edges.push((*edge).clone()); - current = next; - } - - let chain_confidence = EvidenceChain::compute_confidence(&chain_edges); - Some(EvidenceChain { - nodes: chain_nodes, - edges: chain_edges, - chain_confidence, - chain_type, - }) - } - - /// Compute the overall verdict from the evidence node set. - fn compute_verdict( - &self, - nodes: &[EvidenceNode], - hypothesis: &Hypothesis, - ) -> (Verdict, f32) { - // Handle HowTo specially — return procedural steps - if hypothesis.hypothesis_type == HypothesisType::HowTo { - let steps: Vec = nodes - .iter() - .filter(|n| n.evidence_type == EvidenceType::ProceduralStep) - .map(|n| n.content.clone()) - .collect(); - if !steps.is_empty() { - return (Verdict::Procedural(steps), 0.9); - } - } - - let mut support_mass = 0.0f32; - let mut refute_mass = 0.0f32; - - for node in nodes { - match node.evidence_type { - EvidenceType::DirectSupport => support_mass += node.confidence * 1.0, - EvidenceType::IndirectSupport => support_mass += node.confidence * 0.6, - EvidenceType::DirectRefutation => refute_mass += node.confidence * 1.0, - EvidenceType::IndirectRefutation => refute_mass += node.confidence * 0.6, - EvidenceType::CausalAntecedent | EvidenceType::CausalConsequent => { - // Causal evidence weakly supports the hypothesis - support_mass += node.confidence * 0.3; - } - _ => {} - } - } - - let total = support_mass + refute_mass; - - if total < 0.01 { - return (Verdict::Insufficient, 0.0); - } - - let support_fraction = support_mass / total; - let refute_fraction = refute_mass / total; - - // Both sides have substantial mass → Contradictory - if support_fraction >= self.config.contradiction_threshold - && refute_fraction >= self.config.contradiction_threshold - { - return (Verdict::Contradictory, 0.5); - } - - let confidence = support_fraction.clamp(0.0, 1.0); - - if confidence > 0.6 { - (Verdict::Supported(confidence), confidence) - } else if confidence < 0.4 { - let refute_conf = refute_fraction.clamp(0.0, 1.0); - (Verdict::Refuted(refute_conf), refute_conf) - } else { - // Between 0.4 and 0.6 — insufficient evidence to commit - if nodes.len() < 3 { - (Verdict::Insufficient, confidence) - } else { - (Verdict::Contradictory, confidence) - } - } - } - - /// Generate a natural language summary of the reasoning result. - fn build_summary( - &self, - verdict: &Verdict, - primary_evidence: &[EvidenceNode], - hypothesis: &Hypothesis, - ) -> String { - let evidence_snippet: String = primary_evidence - .iter() - .take(3) - .map(|e| format!("\"{}\"", e.content.chars().take(80).collect::())) - .collect::>() - .join("; "); - - match verdict { - Verdict::Supported(conf) => format!( - "Hypothesis \"{}\" is supported with {:.0}% confidence. \ - Key evidence: {}.", - hypothesis.text, - conf * 100.0, - if evidence_snippet.is_empty() { "graph activation patterns".into() } else { evidence_snippet } - ), - Verdict::Refuted(conf) => format!( - "Hypothesis \"{}\" is refuted with {:.0}% confidence. \ - Contradicting evidence: {}.", - hypothesis.text, - conf * 100.0, - if evidence_snippet.is_empty() { "graph activation patterns".into() } else { evidence_snippet } - ), - Verdict::Insufficient => format!( - "Insufficient evidence in the graph to evaluate: \"{}\". \ - More nodes covering this topic are needed.", - hypothesis.text - ), - Verdict::Contradictory => format!( - "Contradictory evidence found for: \"{}\". \ - The graph contains conflicting information: {}.", - hypothesis.text, - if evidence_snippet.is_empty() { "multiple conflicting nodes".into() } else { evidence_snippet } - ), - Verdict::Procedural(steps) => format!( - "Procedural steps for \"{}\": {}.", - hypothesis.text, - steps.iter().enumerate() - .map(|(i, s)| format!("{}. {}", i + 1, s)) - .collect::>() - .join(" ") - ), - } - } -} - -// ── Edge mapping helpers ────────────────────────────────────────────────────── - -fn relation_to_inference_edge(relation: &str) -> InferenceEdgeType { - match relation { - "causes" => InferenceEdgeType::Causes, - "contradicts" => InferenceEdgeType::Contradicts, - "supersedes" => InferenceEdgeType::Implies, - "contains" => InferenceEdgeType::Requires, - "references" => InferenceEdgeType::SimilarTo, - "exemplifies" => InferenceEdgeType::InstanceOf, - "activates" => InferenceEdgeType::Supports, - "temporally_precedes" => InferenceEdgeType::Causes, - // Personhood types map to their closest structural equivalent - "grounded_in" | "reinforced_by" | "derives_from" | "resonates_with" => InferenceEdgeType::Supports, - "in_tension_with" | "challenged_by" => InferenceEdgeType::Contradicts, - "expressed_through" | "shaped_by" => InferenceEdgeType::Implies, - _ => InferenceEdgeType::SimilarTo, - } -} - -fn edge_fits_chain_type(edge_type: &InferenceEdgeType, chain_type: &ChainType) -> bool { - match chain_type { - ChainType::SupportChain => matches!( - edge_type, - InferenceEdgeType::Supports | InferenceEdgeType::Implies | InferenceEdgeType::SimilarTo - ), - ChainType::RefutationChain => matches!( - edge_type, - InferenceEdgeType::Refutes | InferenceEdgeType::Contradicts - ), - ChainType::CausalChain => matches!(edge_type, InferenceEdgeType::Causes), - ChainType::ProcessChain => matches!( - edge_type, - InferenceEdgeType::Causes | InferenceEdgeType::Requires | InferenceEdgeType::Implies - ), - } -} diff --git a/engrams/engram-reasoning/src/lib.rs b/engrams/engram-reasoning/src/lib.rs deleted file mode 100644 index c59ffa8..0000000 --- a/engrams/engram-reasoning/src/lib.rs +++ /dev/null @@ -1,50 +0,0 @@ -/// Engram Reasoning Engine — graph-native inference separated from language generation. -/// -/// # What this crate is -/// -/// This is NOT an LLM wrapper. It is a reasoning system that traverses the Engram -/// knowledge graph to reach conclusions through evidence chains. -/// -/// LLMs: input tokens → transformer → output tokens. Reasoning and generation are -/// the same process. You cannot separate them. -/// -/// This engine: hypothesis → graph traversal → evidence chains → confidence-weighted -/// conclusion. Generation happens separately (a codec converts the conclusion to -/// language). The reasoning IS the traversal. -/// -/// # Quick Start -/// -/// ```rust,no_run -/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier}; -/// use engram_reasoning::{ReasoningEngine, Hypothesis, HypothesisType, ReasoningConfig}; -/// use std::path::Path; -/// use std::sync::{Arc, Mutex}; -/// -/// let db = Arc::new(Mutex::new(EngramDb::open(Path::new("/tmp/engram-reason-test")).unwrap())); -/// let config = ReasoningConfig::default(); -/// let mut engine = ReasoningEngine::new(db, config); -/// -/// let hypothesis = Hypothesis::new( -/// "Spreading activation improves memory retrieval", -/// vec![0.9f32, 0.1, 0.3, 0.7], -/// HypothesisType::IsTrue, -/// ); -/// -/// let result = engine.reason(&hypothesis).unwrap(); -/// println!("Verdict: {:?}", result.conclusion.verdict); -/// println!("Confidence: {:.2}", result.confidence); -/// ``` - -pub mod engine; -pub mod types; - -#[cfg(test)] -mod tests; - -// Re-export the primary public surface -pub use engine::ReasoningEngine; -pub use types::{ - CausalDirection, ChainType, Conclusion, EvidenceChain, EvidenceNode, EvidenceType, - Hypothesis, HypothesisType, InferenceEdge, InferenceEdgeType, ReasoningConfig, - ReasoningResult, Verdict, -}; diff --git a/engrams/engram-reasoning/src/tests.rs b/engrams/engram-reasoning/src/tests.rs deleted file mode 100644 index 9c541d1..0000000 --- a/engrams/engram-reasoning/src/tests.rs +++ /dev/null @@ -1,531 +0,0 @@ -/// Tests for the engram-reasoning engine. -/// -/// Covers: construction, hypothesis creation, evidence classification, -/// confidence propagation, causal chains, contradiction detection, -/// empty-graph behaviour, and full integration scenarios. -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - use tempfile::TempDir; - use uuid::Uuid; - - use engram_core::{Edge, EngramDb, MemoryTier, Node, NodeType}; - - use crate::{ - CausalDirection, EvidenceNode, EvidenceType, Hypothesis, HypothesisType, InferenceEdge, - InferenceEdgeType, ReasoningConfig, ReasoningEngine, Verdict, - }; - - // ── Test fixtures ───────────────────────────────────────────────────────── - - fn make_db() -> (TempDir, Arc>) { - let dir = TempDir::new().expect("tempdir"); - let db = EngramDb::open(dir.path()).expect("open db"); - (dir, Arc::new(Mutex::new(db))) - } - - fn make_engine(db: Arc>) -> ReasoningEngine { - ReasoningEngine::with_default_config(db) - } - - fn embedding(values: &[f32]) -> Vec { - values.to_vec() - } - - fn make_node( - db: &Arc>, - content: &str, - emb: Vec, - node_type: NodeType, - importance: f32, - ) -> Uuid { - let node = Node::new( - node_type, - emb, - content.as_bytes().to_vec(), - MemoryTier::Semantic, - importance, - ); - let id = node.id; - db.lock().unwrap().put_node(node).unwrap(); - id - } - - fn make_edge(db: &Arc>, from: Uuid, to: Uuid, relation: &str) { - let edge = Edge::new(from, to, relation, 0.8); - db.lock().unwrap().put_edge(edge).unwrap(); - } - - // ── Test 1: Engine construction with default config ─────────────────────── - - #[test] - fn test_engine_construction_default() { - let (_dir, db) = make_db(); - let engine = make_engine(db); - assert_eq!(engine.config.max_depth, 5); - assert!((engine.config.min_confidence - 0.05).abs() < f32::EPSILON); - assert_eq!(engine.config.max_evidence_nodes, 50); - assert!((engine.config.contradiction_threshold - 0.4).abs() < f32::EPSILON); - } - - // ── Test 2: Engine construction with custom config ──────────────────────── - - #[test] - fn test_engine_construction_custom_config() { - let (_dir, db) = make_db(); - let config = ReasoningConfig { - max_depth: 3, - min_confidence: 0.1, - max_evidence_nodes: 20, - contradiction_threshold: 0.3, - }; - let engine = ReasoningEngine::new(db, config); - assert_eq!(engine.config.max_depth, 3); - assert_eq!(engine.config.max_evidence_nodes, 20); - } - - // ── Test 3: Hypothesis creation for each type ───────────────────────────── - - #[test] - fn test_hypothesis_creation_is_true() { - let h = Hypothesis::new("X is true", vec![1.0, 0.0], HypothesisType::IsTrue); - assert_eq!(h.hypothesis_type, HypothesisType::IsTrue); - assert_eq!(h.text, "X is true"); - assert!(!h.id.is_nil()); - } - - #[test] - fn test_hypothesis_creation_what_causes() { - let h = Hypothesis::new("What causes X", vec![0.5, 0.5], HypothesisType::WhatCauses); - assert_eq!(h.hypothesis_type, HypothesisType::WhatCauses); - } - - #[test] - fn test_hypothesis_creation_how_to() { - let h = Hypothesis::new("How to do X", vec![0.3, 0.7], HypothesisType::HowTo); - assert_eq!(h.hypothesis_type, HypothesisType::HowTo); - } - - #[test] - fn test_hypothesis_creation_what_is() { - let h = Hypothesis::new("What is X", vec![0.2, 0.8], HypothesisType::WhatIs); - assert_eq!(h.hypothesis_type, HypothesisType::WhatIs); - } - - #[test] - fn test_hypothesis_creation_compare() { - let h = Hypothesis::new("Compare X and Y", vec![0.6, 0.4], HypothesisType::Compare); - assert_eq!(h.hypothesis_type, HypothesisType::Compare); - } - - // ── Test 4: Empty graph → Insufficient ─────────────────────────────────── - - #[test] - fn test_empty_graph_gives_insufficient() { - let (_dir, db) = make_db(); - let mut engine = make_engine(db); - let h = Hypothesis::new("anything", vec![1.0, 0.0, 0.0, 0.0], HypothesisType::IsTrue); - let result = engine.reason(&h).unwrap(); - assert!( - matches!(result.conclusion.verdict, Verdict::Insufficient), - "Expected Insufficient, got {:?}", - result.conclusion.verdict - ); - assert_eq!(result.confidence, 0.0); - } - - // ── Test 5: Evidence classification — DirectSupport ────────────────────── - - #[test] - fn test_evidence_classification_direct_support() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - // Node with embedding very close to hypothesis - let node = Node::new( - NodeType::Memory, - vec![0.98_f32, 0.02, 0.0, 0.0], - b"Spreading activation is effective".to_vec(), - MemoryTier::Semantic, - 0.9, - ); - let h = Hypothesis::new( - "Spreading activation is effective", - vec![1.0_f32, 0.0, 0.0, 0.0], - HypothesisType::IsTrue, - ); - let ev = engine.classify_evidence(&node, &h, 0.8, 0); - assert_eq!(ev.evidence_type, EvidenceType::DirectSupport); - } - - // ── Test 6: Evidence classification — DirectRefutation ─────────────────── - - #[test] - fn test_evidence_classification_direct_refutation() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - let node = Node::new( - NodeType::Memory, - vec![0.99_f32, 0.01, 0.0, 0.0], - b"Spreading activation is not effective and is wrong".to_vec(), - MemoryTier::Semantic, - 0.9, - ); - let h = Hypothesis::new( - "Spreading activation is effective", - vec![1.0_f32, 0.0, 0.0, 0.0], - HypothesisType::IsTrue, - ); - let ev = engine.classify_evidence(&node, &h, 0.7, 0); - assert_eq!(ev.evidence_type, EvidenceType::DirectRefutation); - } - - // ── Test 7: Evidence classification — ContextualFact ───────────────────── - - #[test] - fn test_evidence_classification_contextual_fact() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - // Node with low similarity to hypothesis - let node = Node::new( - NodeType::Memory, - vec![0.0_f32, 0.0, 1.0, 0.0], // orthogonal - b"Some unrelated content".to_vec(), - MemoryTier::Semantic, - 0.5, - ); - let h = Hypothesis::new( - "Spreading activation", - vec![1.0_f32, 0.0, 0.0, 0.0], - HypothesisType::IsTrue, - ); - let ev = engine.classify_evidence(&node, &h, 0.3, 2); - assert_eq!(ev.evidence_type, EvidenceType::ContextualFact); - } - - // ── Test 8: Evidence classification — ProceduralStep ───────────────────── - - #[test] - fn test_evidence_classification_procedural_step() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - let node = Node::new( - NodeType::Process, - vec![0.95_f32, 0.05, 0.0, 0.0], - b"Step 1: initialize the graph".to_vec(), - MemoryTier::Procedural, - 0.8, - ); - let h = Hypothesis::new( - "How to build a knowledge graph", - vec![1.0_f32, 0.0, 0.0, 0.0], - HypothesisType::HowTo, - ); - let ev = engine.classify_evidence(&node, &h, 0.9, 0); - assert_eq!(ev.evidence_type, EvidenceType::ProceduralStep); - } - - // ── Test 9: Confidence propagation ─────────────────────────────────────── - - #[test] - fn test_confidence_propagation_boost() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - let id_a = Uuid::new_v4(); - let id_b = Uuid::new_v4(); - - let mut nodes = vec![ - EvidenceNode { - engram_node_id: id_a, - content: "Node A".into(), - evidence_type: EvidenceType::DirectSupport, - confidence: 0.8, - activation_strength: 0.8, - hops_from_seed: 0, - }, - EvidenceNode { - engram_node_id: id_b, - content: "Node B".into(), - evidence_type: EvidenceType::IndirectSupport, - confidence: 0.2, - activation_strength: 0.4, - hops_from_seed: 1, - }, - ]; - - let edges = vec![InferenceEdge { - from_node: id_a, - to_node: id_b, - edge_type: InferenceEdgeType::Supports, - strength: 0.9, - engram_edge_id: None, - }]; - - let original_b_confidence = nodes[1].confidence; - engine.propagate_confidence(&mut nodes, &edges); - - // Node B's confidence should be boosted by the incoming support from A - assert!( - nodes[1].confidence > original_b_confidence, - "Expected confidence to increase from {}, got {}", - original_b_confidence, - nodes[1].confidence - ); - } - - // ── Test 10: Confidence propagation — refutation reduces ───────────────── - - #[test] - fn test_confidence_propagation_refutation_reduces() { - let (_dir, db) = make_db(); - let engine = make_engine(db.clone()); - - let id_a = Uuid::new_v4(); - let id_b = Uuid::new_v4(); - - let mut nodes = vec![ - EvidenceNode { - engram_node_id: id_a, - content: "Strong refuting node".into(), - evidence_type: EvidenceType::DirectRefutation, - confidence: 0.9, - activation_strength: 0.9, - hops_from_seed: 0, - }, - EvidenceNode { - engram_node_id: id_b, - content: "Downstream node".into(), - evidence_type: EvidenceType::IndirectSupport, - confidence: 0.6, - activation_strength: 0.5, - hops_from_seed: 1, - }, - ]; - - let edges = vec![InferenceEdge { - from_node: id_a, - to_node: id_b, - edge_type: InferenceEdgeType::Refutes, - strength: 0.8, - engram_edge_id: None, - }]; - - let original_conf = nodes[1].confidence; - engine.propagate_confidence(&mut nodes, &edges); - - assert!( - nodes[1].confidence < original_conf, - "Expected confidence to decrease from {}, got {}", - original_conf, - nodes[1].confidence - ); - } - - // ── Test 11: Causal chain finding ───────────────────────────────────────── - - #[test] - fn test_causal_chain_finding() { - let (_dir, db) = make_db(); - - let emb_a = embedding(&[1.0, 0.0, 0.0, 0.0]); - let emb_b = embedding(&[0.9, 0.1, 0.0, 0.0]); - let emb_c = embedding(&[0.8, 0.2, 0.0, 0.0]); - - let id_a = make_node(&db, "Heat causes expansion", emb_a.clone(), NodeType::Concept, 0.9); - let id_b = make_node(&db, "Expansion causes pressure", emb_b, NodeType::Concept, 0.8); - let id_c = make_node(&db, "Pressure causes rupture", emb_c, NodeType::Concept, 0.7); - - make_edge(&db, id_a, id_b, "causes"); - make_edge(&db, id_b, id_c, "causes"); - - let mut engine = make_engine(db.clone()); - let chains = engine.causal_chain(&emb_a, CausalDirection::Forward).unwrap(); - - assert!(!chains.is_empty(), "Expected at least one causal chain"); - let chain = &chains[0]; - assert!(chain.nodes.len() >= 2, "Expected chain with at least 2 nodes"); - assert_eq!(chain.chain_type, crate::ChainType::CausalChain); - } - - // ── Test 12: Contradiction detection ───────────────────────────────────── - - #[test] - fn test_contradiction_detection_via_edge() { - let (_dir, db) = make_db(); - - let emb_topic = embedding(&[1.0_f32, 0.0, 0.0, 0.0]); - - let id_a = make_node( - &db, - "Water boils at 100°C", - embedding(&[0.95, 0.05, 0.0, 0.0]), - NodeType::Memory, - 0.9, - ); - let id_b = make_node( - &db, - "Water does not boil at 100°C", - embedding(&[0.93, 0.07, 0.0, 0.0]), - NodeType::Memory, - 0.9, - ); - make_edge(&db, id_a, id_b, "contradicts"); - - let mut engine = make_engine(db.clone()); - let contradictions = engine.find_contradictions(&emb_topic).unwrap(); - - assert!( - !contradictions.is_empty(), - "Expected at least one contradiction pair" - ); - } - - // ── Test 13: Integration — insert nodes, reason, check verdict ──────────── - - #[test] - fn test_integration_supported_verdict() { - let (_dir, db) = make_db(); - - // Insert several nodes that strongly support the hypothesis - let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]); - - for i in 0..5 { - let content = format!("Evidence {} supporting spreading activation memory retrieval", i); - let emb = embedding(&[0.92 - i as f32 * 0.01, 0.08 + i as f32 * 0.01, 0.0, 0.0]); - make_node(&db, &content, emb, NodeType::Memory, 0.9); - } - - let mut engine = make_engine(db.clone()); - let h = Hypothesis::new( - "Spreading activation improves memory retrieval", - hyp_emb, - HypothesisType::IsTrue, - ); - let result = engine.reason(&h).unwrap(); - - // With several high-similarity supporting nodes, expect Supported or at least - // not Insufficient - assert!( - !matches!(result.conclusion.verdict, Verdict::Insufficient), - "Expected a reasoned verdict, got Insufficient" - ); - assert!(result.nodes_visited > 0); - assert!(result.reasoning_steps > 0); - } - - // ── Test 14: Integration — refutation via negation ──────────────────────── - - #[test] - fn test_integration_refutation_via_negation() { - let (_dir, db) = make_db(); - - let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]); - - // Insert nodes with negation content, similar embedding - for i in 0..5 { - let content = format!("Spreading activation does not improve retrieval — test {}", i); - let emb = embedding(&[0.93 - i as f32 * 0.01, 0.07 + i as f32 * 0.01, 0.0, 0.0]); - make_node(&db, &content, emb, NodeType::Memory, 0.85); - } - - let mut engine = make_engine(db.clone()); - let h = Hypothesis::new( - "Spreading activation improves retrieval", - hyp_emb, - HypothesisType::IsTrue, - ); - let result = engine.reason(&h).unwrap(); - // The primary evidence should be mostly refutation - let has_refutation = result.conclusion.primary_evidence.iter().any(|e| { - matches!( - e.evidence_type, - EvidenceType::DirectRefutation | EvidenceType::IndirectRefutation - ) - }); - // Not all graphs will surface refutation at confidence level, but the - // reasoning machinery should run without errors - let _ = has_refutation; - assert!(result.reasoning_steps > 0); - } - - // ── Test 15: Procedural chain for HowTo ─────────────────────────────────── - - #[test] - fn test_procedural_chain_for_how_to() { - let (_dir, db) = make_db(); - - let goal_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]); - - // Create a chain of process nodes - let id_1 = make_node( - &db, - "Step 1: Define the graph schema", - embedding(&[0.95, 0.05, 0.0, 0.0]), - NodeType::Process, - 0.9, - ); - let id_2 = make_node( - &db, - "Step 2: Insert initial nodes", - embedding(&[0.90, 0.10, 0.0, 0.0]), - NodeType::Process, - 0.9, - ); - let id_3 = make_node( - &db, - "Step 3: Create edges between nodes", - embedding(&[0.85, 0.15, 0.0, 0.0]), - NodeType::Process, - 0.9, - ); - make_edge(&db, id_1, id_2, "causes"); - make_edge(&db, id_2, id_3, "causes"); - - let mut engine = make_engine(db.clone()); - let steps = engine.procedural_chain(&goal_emb).unwrap(); - - assert!(!steps.is_empty(), "Expected procedural steps"); - } - - // ── Test 16: Evidence chain confidence is product of edge strengths ──────── - - #[test] - fn test_evidence_chain_confidence_product() { - let edges = vec![ - InferenceEdge { - from_node: Uuid::new_v4(), - to_node: Uuid::new_v4(), - edge_type: InferenceEdgeType::Supports, - strength: 0.8, - engram_edge_id: None, - }, - InferenceEdge { - from_node: Uuid::new_v4(), - to_node: Uuid::new_v4(), - edge_type: InferenceEdgeType::Implies, - strength: 0.5, - engram_edge_id: None, - }, - ]; - let confidence = crate::EvidenceChain::compute_confidence(&edges); - let expected = 0.8 * 0.5; - assert!( - (confidence - expected).abs() < 1e-6, - "Expected {}, got {}", - expected, - confidence - ); - } - - // ── Test 17: Empty edges give chain confidence 1.0 ─────────────────────── - - #[test] - fn test_empty_chain_confidence_is_one() { - let confidence = crate::EvidenceChain::compute_confidence(&[]); - assert!((confidence - 1.0).abs() < f32::EPSILON); - } -} diff --git a/engrams/engram-reasoning/src/types.rs b/engrams/engram-reasoning/src/types.rs deleted file mode 100644 index ead8af8..0000000 --- a/engrams/engram-reasoning/src/types.rs +++ /dev/null @@ -1,247 +0,0 @@ -/// Core types for the Engram reasoning engine. -/// -/// These types represent hypotheses, evidence nodes, inference edges, and -/// reasoning results. They are graph-native: every concept is grounded in -/// the Engram knowledge graph rather than in token distributions. -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ── Hypothesis ──────────────────────────────────────────────────────────────── - -/// A hypothesis to be evaluated by the reasoning engine. -/// -/// "Is X true?", "What causes Y?", "How do I do Z?" — all expressed as a typed -/// claim whose embedding anchors the graph traversal. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Hypothesis { - pub id: Uuid, - /// Natural language text of the hypothesis - pub text: String, - /// Semantic embedding — the hypothesis's position in meaning-space. - /// Used to seed the spreading activation and to classify evidence. - pub embedding: Vec, - pub hypothesis_type: HypothesisType, -} - -impl Hypothesis { - pub fn new(text: impl Into, embedding: Vec, hypothesis_type: HypothesisType) -> Self { - Self { - id: Uuid::new_v4(), - text: text.into(), - embedding, - hypothesis_type, - } - } -} - -/// The semantic class of a hypothesis — governs how the engine traverses and -/// classifies evidence. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum HypothesisType { - /// Boolean claim — find supporting or refuting evidence - IsTrue, - /// Causal query — find cause chains leading to the concept - WhatCauses, - /// Procedural query — find ordered process chains for achieving the goal - HowTo, - /// Definitional query — find semantic clusters around the concept - WhatIs, - /// Comparison — find similarities and differences between two concepts - Compare, -} - -// ── Evidence ────────────────────────────────────────────────────────────────── - -/// A node in the evidence graph — an Engram node annotated with its evidential -/// role relative to the hypothesis being evaluated. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvidenceNode { - /// The backing Engram graph node's UUID - pub engram_node_id: Uuid, - /// Decoded text content of the node - pub content: String, - /// How this node relates to the hypothesis - pub evidence_type: EvidenceType, - /// Intrinsic confidence (from node importance and salience), 0.0–1.0 - pub confidence: f32, - /// Activation strength at this node from spreading activation - pub activation_strength: f32, - /// Number of hops from the hypothesis seed nodes - pub hops_from_seed: u32, -} - -/// The role an evidence node plays relative to the hypothesis. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum EvidenceType { - /// Directly supports the hypothesis (cosine sim > 0.8, no negation) - DirectSupport, - /// Directly refutes the hypothesis (cosine sim > 0.8, negation signals) - DirectRefutation, - /// Supports via an inference chain (cosine sim 0.5–0.8) - IndirectSupport, - /// Refutes via an inference chain (cosine sim 0.5–0.8, negation) - IndirectRefutation, - /// Relevant context, neither clearly for nor against - ContextualFact, - /// A step in a process chain (node type = Process or Procedure) - ProceduralStep, - /// A causal antecedent — causes something in the chain - CausalAntecedent, - /// A causal consequent — caused by something in the chain - CausalConsequent, -} - -// ── Inference edges ─────────────────────────────────────────────────────────── - -/// A directed inference edge in the evidence graph. -/// -/// May be backed by a real Engram edge (when the relation type maps cleanly) -/// or constructed by the engine from semantic proximity. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InferenceEdge { - pub from_node: Uuid, - pub to_node: Uuid, - pub edge_type: InferenceEdgeType, - /// Strength of this inference step, 0.0–1.0 - pub strength: f32, - /// The backing Engram edge UUID, if this edge corresponds to a stored relation - pub engram_edge_id: Option, -} - -/// The semantic type of an inference edge. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum InferenceEdgeType { - /// A supports B - Supports, - /// A refutes B - Refutes, - /// A causes B - Causes, - /// A requires B (precondition) - Requires, - /// A implies B (logical entailment) - Implies, - /// A contradicts B - Contradicts, - /// A is semantically similar to B - SimilarTo, - /// A is an instance of B - InstanceOf, -} - -// ── Evidence chains ─────────────────────────────────────────────────────────── - -/// An ordered sequence of evidence nodes and the edges connecting them — -/// a single thread of reasoning from the hypothesis to a conclusion. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvidenceChain { - pub nodes: Vec, - pub edges: Vec, - /// Product of edge strengths along the chain — lower for longer/weaker chains - pub chain_confidence: f32, - pub chain_type: ChainType, -} - -impl EvidenceChain { - /// Compute chain_confidence as the product of all edge strengths. - pub fn compute_confidence(edges: &[InferenceEdge]) -> f32 { - if edges.is_empty() { - return 1.0; - } - edges.iter().map(|e| e.strength).product() - } -} - -/// The logical character of an evidence chain. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ChainType { - /// Chain that supports the hypothesis - SupportChain, - /// Chain that refutes the hypothesis - RefutationChain, - /// Chain tracing cause→effect relationships - CausalChain, - /// Chain tracing ordered procedural steps - ProcessChain, -} - -// ── Results ─────────────────────────────────────────────────────────────────── - -/// The full output of a reasoning pass over the knowledge graph. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReasoningResult { - pub hypothesis: Hypothesis, - pub conclusion: Conclusion, - /// All evidence chains discovered during traversal - pub evidence_chains: Vec, - /// Overall confidence in the conclusion, 0.0–1.0 - pub confidence: f32, - /// Total reasoning steps (graph operations) performed - pub reasoning_steps: u32, - /// Total graph nodes visited during traversal - pub nodes_visited: u32, -} - -/// The conclusion reached by the reasoning engine. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conclusion { - pub verdict: Verdict, - /// Natural language summary of the reasoning path - pub summary: String, - pub confidence: f32, - /// The strongest pieces of evidence driving this conclusion - pub primary_evidence: Vec, -} - -/// The verdict produced by the reasoning engine. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Verdict { - /// Hypothesis supported with this confidence - Supported(f32), - /// Hypothesis refuted with this confidence - Refuted(f32), - /// Not enough evidence in the graph to reach a conclusion - Insufficient, - /// Conflicting evidence — cannot resolve without more context - Contradictory, - /// HowTo verdict — ordered steps found in the graph - Procedural(Vec), -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -/// Tuning parameters for the reasoning engine. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReasoningConfig { - /// Maximum hops from seed nodes during spreading activation (default: 5) - pub max_depth: u32, - /// Prune nodes with activation strength below this (default: 0.05) - pub min_confidence: f32, - /// Cap on the number of evidence nodes collected (default: 50) - pub max_evidence_nodes: u32, - /// If both support and refutation mass exceed this fraction of total, declare - /// Contradictory rather than Supported/Refuted (default: 0.4) - pub contradiction_threshold: f32, -} - -impl Default for ReasoningConfig { - fn default() -> Self { - Self { - max_depth: 5, - min_confidence: 0.05, - max_evidence_nodes: 50, - contradiction_threshold: 0.4, - } - } -} - -/// Direction for causal chain traversal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum CausalDirection { - /// Find what causes the given concept (backward traversal) - Backward, - /// Find what the given concept causes (forward traversal) - Forward, - /// Find both directions - Both, -} diff --git a/engrams/engram-server/Cargo.toml b/engrams/engram-server/Cargo.toml deleted file mode 100644 index 089e808..0000000 --- a/engrams/engram-server/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "engram-server" -version = "0.1.0" -edition = "2021" -description = "HTTP server for Engram — REST API + sync endpoints + swarm activation" -license = "MIT" - -[[bin]] -name = "engram-server" -path = "src/main.rs" - -[dependencies] -engram-core = { path = "../engram-core" } -engram-reasoning = { path = "../engram-reasoning" } -engram-sync = { path = "../engram-sync" } -engram-projection = { path = "../engram-projection" } -engram-tx = { path = "../engram-tx" } -engram-crypto = { path = "../engram-crypto" } -sled = "0.34" -axum = { version = "0.7", features = ["json"] } -tokio = { version = "1", features = ["full"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["cors", "fs"] } -rust-embed = { version = "8", features = ["axum"] } -mime_guess = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -anyhow = "1" -thiserror = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -[dev-dependencies] -axum-test = "14" -tokio = { version = "1", features = ["full"] } -tempfile = "3" diff --git a/engrams/engram-server/src/auth.rs b/engrams/engram-server/src/auth.rs deleted file mode 100644 index 8c92db8..0000000 --- a/engrams/engram-server/src/auth.rs +++ /dev/null @@ -1,31 +0,0 @@ -/// Auth middleware for sync endpoints. -/// -/// Sync and swarm endpoints require `Authorization: Bearer {api_key}`. -/// The API key is configured at server startup and stored in AppState. -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -use std::sync::Arc; - -use crate::state::AppState; - -/// Axum middleware that checks the Authorization header on sync/swarm routes. -pub async fn require_auth( - State(state): State>, - req: Request, - next: Next, -) -> Result { - let api_key = req - .headers() - .get("Authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")); - - match api_key { - Some(key) if key == state.api_key => Ok(next.run(req).await), - _ => Err(StatusCode::UNAUTHORIZED), - } -} diff --git a/engrams/engram-server/src/main.rs b/engrams/engram-server/src/main.rs deleted file mode 100644 index 34c8a9b..0000000 --- a/engrams/engram-server/src/main.rs +++ /dev/null @@ -1,287 +0,0 @@ -/// Engram Server — HTTP API for Engram with sync and swarm activation. -/// -/// # Endpoints -/// -/// ## Core -/// GET /stats — node/edge counts -/// POST /nodes — create a node -/// GET /nodes/:id — get a node -/// POST /edges — create an edge -/// GET /nodes/:id/edges — list edges from a node -/// POST /activate — spreading activation -/// POST /search — embedding search -/// POST /decay — apply salience decay -/// POST /consolidate — promote Episodic → Semantic -/// -/// ## Sync (auth required) -/// GET /sync/delta?since={ms}&peer_id={uuid} — generate delta -/// POST /sync/push — receive incoming delta -/// POST /sync/peers — register peer -/// GET /sync/peers — list peers -/// DELETE /sync/peers/:id — remove peer -/// -/// ## Swarm -/// POST /swarm/activate — distributed activation (auth required) -/// GET /swarm/status — peer health (auth required) -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use axum::{ - body::Body, - http::{header, StatusCode}, - middleware, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Router, -}; -use engram_core::EngramDb; -use engram_projection::registry::ProjectionRegistry; -use engram_sync::{SyncConfig, SyncEngine}; -use engram_tx::TransactionEngine; -use mime_guess::from_path; -use rust_embed::RustEmbed; -use tokio::time::interval; -use tower_http::cors::CorsLayer; -use tracing::info; -use tracing_subscriber::EnvFilter; - -#[derive(RustEmbed)] -#[folder = "../../studio/"] -struct Studio; - -async fn serve_studio_index() -> impl IntoResponse { - match Studio::get("index.html") { - Some(content) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(content.data.into_owned())) - .unwrap(), - None => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("Studio not found")) - .unwrap(), - } -} - -async fn serve_studio_asset(uri: axum::extract::Path) -> impl IntoResponse { - let path = uri.0; - match Studio::get(&path) { - Some(content) => { - let mime = from_path(&path).first_or_octet_stream(); - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime.as_ref()) - .body(Body::from(content.data.into_owned())) - .unwrap() - } - None => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("Not found")) - .unwrap(), - } -} - -mod auth; -mod routes; -mod state; - -use auth::require_auth; -use state::AppState; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Logging - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .init(); - - // Configuration from environment (with sensible defaults) - let db_path = std::env::var("ENGRAM_DB_PATH").unwrap_or_else(|_| "./engram-data".to_string()); - let bind_addr = std::env::var("ENGRAM_BIND").unwrap_or_else(|_| "0.0.0.0:8742".to_string()); - let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_else(|_| { - let key = uuid::Uuid::new_v4().to_string(); - eprintln!("No ENGRAM_API_KEY set — generated key: {}", key); - key - }); - - // Open database - let db = EngramDb::open(&PathBuf::from(&db_path))?; - let db = Arc::new(Mutex::new(db)); - - // Transaction engine (separate sled db alongside the main db) - let tx_log_path = format!("{}-tx-log", db_path); - let tx_log_db = sled::open(&tx_log_path)?; - let tx_engine = Arc::new(Mutex::new(TransactionEngine::new( - db.clone(), - tx_log_db, - Some(uuid::Uuid::new_v4()), - ))); - - // Projection registry - let projection_registry = Arc::new(Mutex::new(ProjectionRegistry::new())); - - info!("Database opened at {}", db_path); - - // Sync engine — wrapped in tokio::sync::Mutex so it can be held across .await - let sync_config = SyncConfig { - our_id: uuid::Uuid::new_v4(), - our_name: std::env::var("ENGRAM_PEER_NAME").unwrap_or_else(|_| "engram-local".to_string()), - api_key: api_key.clone(), - default_sync_tiers: vec![engram_core::types::MemoryTier::Semantic], - sync_interval_secs: std::env::var("ENGRAM_SYNC_INTERVAL_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(300), - }; - let sync_interval_secs = sync_config.sync_interval_secs; - let sync_engine = Arc::new(tokio::sync::Mutex::new(SyncEngine::new(db.clone(), sync_config))); - - { - let e = sync_engine.lock().await; - info!( - peer_name = e.our_name(), - peer_id = %e.our_id(), - sync_interval_secs, - "Sync engine ready" - ); - } - - // Background sync task — tokio::sync::Mutex guard is Send-safe - { - let engine_arc = sync_engine.clone(); - tokio::spawn(async move { - let mut ticker = interval(Duration::from_secs(sync_interval_secs)); - loop { - ticker.tick().await; - let report = { - let mut e = engine_arc.lock().await; - e.sync_all().await - }; - if report.peers_synced > 0 || !report.errors.is_empty() { - info!( - peers_synced = report.peers_synced, - nodes_received = report.nodes_received, - nodes_sent = report.nodes_sent, - errors = report.errors.len(), - "Sync cycle complete" - ); - } - } - }); - } - - // Shared state - let state = Arc::new(AppState { - db: db.clone(), - sync_engine: sync_engine.clone(), - api_key: api_key.clone(), - projection_registry, - tx_engine, - }); - - // Protected sync/swarm routes (auth middleware applied) - let sync_routes = Router::new() - .route("/sync/delta", get(routes::sync::get_delta)) - .route("/sync/push", post(routes::sync::push_delta)) - .route("/sync/peers", get(routes::sync::list_peers)) - .route("/sync/peers", post(routes::sync::register_peer)) - .route("/sync/peers/:id", delete(routes::sync::delete_peer)) - .route("/swarm/activate", post(routes::swarm::swarm_activate)) - .route("/swarm/status", get(routes::swarm::swarm_status)) - .layer(middleware::from_fn_with_state( - state.clone(), - require_auth, - )); - - // Open core routes (no auth) - let core_routes = Router::new() - .route("/stats", get(routes::core::get_stats)) - .route("/nodes", post(routes::core::create_node)) - .route("/nodes/list", get(routes::core::list_nodes)) - .route("/nodes/:id", get(routes::core::get_node)) - .route("/edges", post(routes::core::create_edge)) - .route("/nodes/:id/edges", get(routes::core::get_edges_from)) - .route("/activate", post(routes::core::activate)) - .route("/search", post(routes::core::search_embedding)) - .route("/decay", post(routes::core::decay)) - .route("/consolidate", post(routes::core::consolidate)); - - // Projection routes (no auth) - let projection_routes = Router::new() - .route("/projections", post(routes::projection::register_projection)) - .route("/projections", get(routes::projection::list_projections)) - .route( - "/projections/:name/schema", - get(routes::projection::get_projection_schema), - ) - .route( - "/projections/:name/query", - post(routes::projection::query_projection), - ); - - // Transaction routes (no auth — add auth layer if needed) - let tx_routes = Router::new() - .route("/tx/apply", post(routes::tx::tx_apply)) - .route("/tx/rollback/:command_id", post(routes::tx::tx_rollback)) - .route("/tx/history", get(routes::tx::tx_history)) - .route("/tx/chain/:command_id", get(routes::tx::tx_causal_chain)); - - // Reasoning routes (no auth — graph-native inference) - let reasoning_routes = Router::new() - .route("/reason", post(routes::reasoning::reason)) - .route("/reason/causal", post(routes::reasoning::causal)) - .route("/reason/contradictions", post(routes::reasoning::contradictions)); - - let app = Router::new() - // Studio - .route("/", get(serve_studio_index)) - .route("/studio", get(serve_studio_index)) - // Core API - .route("/stats", get(routes::core::get_stats)) - .route("/nodes", post(routes::core::create_node)) - .route("/nodes/list", get(routes::core::list_nodes)) - .route("/nodes/:id", get(routes::core::get_node)) - .route("/edges", post(routes::core::create_edge)) - .route("/nodes/:id/edges", get(routes::core::get_edges_from)) - .route("/activate", post(routes::core::activate)) - .route("/search", post(routes::core::search_embedding)) - .route("/decay", post(routes::core::decay)) - .route("/consolidate", post(routes::core::consolidate)) - // Projection - .route("/projections", post(routes::projection::register_projection)) - .route("/projections", get(routes::projection::list_projections)) - .route("/projections/:name/schema", get(routes::projection::get_projection_schema)) - .route("/projections/:name/query", post(routes::projection::query_projection)) - // Transactions - .route("/tx/apply", post(routes::tx::tx_apply)) - .route("/tx/rollback/:command_id", post(routes::tx::tx_rollback)) - .route("/tx/history", get(routes::tx::tx_history)) - .route("/tx/chain/:command_id", get(routes::tx::tx_causal_chain)) - // Reasoning - .route("/reason", post(routes::reasoning::reason)) - .route("/reason/causal", post(routes::reasoning::causal)) - .route("/reason/contradictions", post(routes::reasoning::contradictions)) - // Sync + Swarm routes added directly (auth is in the handlers themselves for now) - .route("/sync/delta", get(routes::sync::get_delta)) - .route("/sync/push", post(routes::sync::push_delta)) - .route("/sync/peers", get(routes::sync::list_peers)) - .route("/sync/peers", post(routes::sync::register_peer)) - .route("/sync/peers/:id", delete(routes::sync::delete_peer)) - .route("/swarm/activate", post(routes::swarm::swarm_activate)) - .route("/swarm/status", get(routes::swarm::swarm_status)) - .fallback(|uri: axum::http::Uri| async move { - tracing::warn!("FALLBACK hit for: {}", uri); - (axum::http::StatusCode::NOT_FOUND, format!("FALLBACK: {}", uri)) - }) - .layer(CorsLayer::permissive()) - .with_state(state); - - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - info!("Engram server listening on {}", bind_addr); - axum::serve(listener, app).await?; - - Ok(()) -} diff --git a/engrams/engram-server/src/routes/core.rs b/engrams/engram-server/src/routes/core.rs deleted file mode 100644 index 4c63807..0000000 --- a/engrams/engram-server/src/routes/core.rs +++ /dev/null @@ -1,246 +0,0 @@ -/// Core Engram API routes — nodes, edges, activation, search. -use axum::{ - extract::{Path, State}, - http::StatusCode, - Json, -}; -use engram_core::types::{Edge, MemoryTier, Node, NodeType}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::state::AppState; - -// ── Stats ───────────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct StatsResponse { - pub nodes: usize, - pub edges: usize, -} - -pub async fn get_stats( - State(state): State>, -) -> Result, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let nodes = db.node_count().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let edges = db.edge_count().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(StatsResponse { nodes, edges })) -} - -// ── Nodes ───────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct CreateNodeRequest { - pub node_type: NodeType, - pub embedding: Vec, - pub content: Vec, - pub tier: MemoryTier, - pub importance: f32, -} - -#[derive(Serialize)] -pub struct CreateNodeResponse { - pub id: Uuid, -} - -pub async fn create_node( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let node = Node::new(req.node_type, req.embedding, req.content, req.tier, req.importance); - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let id = db.put_node(node).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(CreateNodeResponse { id })) -} - -/// List all nodes (scan-based, no embedding needed) -pub async fn list_nodes( - State(state): State>, -) -> Result>, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let nodes = db.scan_nodes().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(nodes)) -} - -pub async fn get_node( - State(state): State>, - Path(id_str): Path, -) -> Result, StatusCode> { - tracing::info!("get_node called with raw id={}", id_str); - let id = id_str.parse::().map_err(|e| { - tracing::warn!("get_node: invalid UUID '{}': {}", id_str, e); - StatusCode::BAD_REQUEST - })?; - let db = state.db.lock().map_err(|_| { - tracing::error!("get_node: mutex poisoned"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - match db.get_node(id) { - Ok(Some(node)) => { - tracing::info!("get_node: found node id={}", id); - Ok(Json(node)) - } - Ok(None) => { - tracing::warn!("get_node: node not found id={}", id); - Err(StatusCode::NOT_FOUND) - } - Err(e) => { - tracing::error!("get_node: db error: {:?}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -// ── Edges ───────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct CreateEdgeRequest { - pub from_id: Uuid, - pub to_id: Uuid, - /// Edge type name, e.g. `"causes"`, `"resonates_with"`, or any dynamic type. - pub relation: String, - pub weight: f32, -} - -pub async fn create_edge( - State(state): State>, - Json(req): Json, -) -> Result { - let edge = Edge::new(req.from_id, req.to_id, req.relation, req.weight); - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - db.put_edge(edge).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::CREATED) -} - -pub async fn get_edges_from( - State(state): State>, - Path(id): Path, -) -> Result>, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let edges = db.get_edges_from(id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(edges)) -} - -// ── Activation ──────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ActivateRequest { - pub seeds: Vec, - pub query_embedding: Vec, - #[serde(default = "default_depth")] - pub max_depth: u8, - #[serde(default = "default_limit")] - pub limit: usize, -} - -fn default_depth() -> u8 { 3 } -fn default_limit() -> usize { 10 } - -#[derive(Serialize)] -pub struct ActivateResponse { - pub results: Vec, -} - -#[derive(Serialize)] -pub struct ActivatedNodeJson { - pub node: Node, - pub activation_strength: f32, - pub hops: u8, -} - -pub async fn activate( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let results = db - .activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(ActivateResponse { - results: results - .into_iter() - .map(|a| ActivatedNodeJson { - node: a.node, - activation_strength: a.activation_strength, - hops: a.hops, - }) - .collect(), - })) -} - -// ── Search ──────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct SearchRequest { - pub embedding: Vec, - #[serde(default = "default_limit")] - pub limit: usize, -} - -#[derive(Serialize)] -pub struct SearchResponse { - pub results: Vec, -} - -#[derive(Serialize)] -pub struct ScoredNodeJson { - pub node: Node, - pub score: f32, -} - -pub async fn search_embedding( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let results = db - .search_embedding(&req.embedding, req.limit) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(SearchResponse { - results: results - .into_iter() - .map(|s| ScoredNodeJson { node: s.node, score: s.score }) - .collect(), - })) -} - -// ── Decay / Consolidate ─────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct DecayRequest { - pub factor: f32, -} - -#[derive(Serialize)] -pub struct DecayResponse { - pub nodes_updated: usize, -} - -pub async fn decay( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let nodes_updated = db.decay(req.factor).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(DecayResponse { nodes_updated })) -} - -#[derive(Serialize)] -pub struct ConsolidateResponse { - pub promoted: usize, -} - -pub async fn consolidate( - State(state): State>, -) -> Result, StatusCode> { - use engram_core::ConsolidationConfig; - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let report = db - .consolidate(&ConsolidationConfig::default()) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(ConsolidateResponse { promoted: report.promoted })) -} - diff --git a/engrams/engram-server/src/routes/mod.rs b/engrams/engram-server/src/routes/mod.rs deleted file mode 100644 index 42aebca..0000000 --- a/engrams/engram-server/src/routes/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod core; -pub mod projection; -pub mod reasoning; -pub mod sync; -pub mod swarm; -pub mod tx; diff --git a/engrams/engram-server/src/routes/projection.rs b/engrams/engram-server/src/routes/projection.rs deleted file mode 100644 index 8994cfd..0000000 --- a/engrams/engram-server/src/routes/projection.rs +++ /dev/null @@ -1,121 +0,0 @@ -/// Projection API routes. -/// -/// POST /projections — register a projection schema -/// GET /projections — list all registered schemas -/// POST /projections/{name}/query — run activation then project results -/// GET /projections/{name}/schema — get a schema definition -use axum::{ - extract::{Path, State}, - http::StatusCode, - Json, -}; -use engram_projection::{ - engine::ProjectionEngine, - schema::{ProjectionResult, ProjectionSchema}, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::state::AppState; - -// ── POST /projections ───────────────────────────────────────────────────────── - -pub async fn register_projection( - State(state): State>, - Json(schema): Json, -) -> Result { - let mut reg = state - .projection_registry - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - reg.upsert(schema) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::CREATED) -} - -// ── GET /projections ────────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct ListProjectionsResponse { - pub schemas: Vec, -} - -pub async fn list_projections( - State(state): State>, -) -> Result, StatusCode> { - let reg = state - .projection_registry - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let schemas = reg.list().into_iter().cloned().collect(); - Ok(Json(ListProjectionsResponse { schemas })) -} - -// ── GET /projections/{name}/schema ──────────────────────────────────────────── - -pub async fn get_projection_schema( - State(state): State>, - Path(name): Path, -) -> Result, StatusCode> { - let reg = state - .projection_registry - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - match reg.get(&name) { - Ok(schema) => Ok(Json(schema.clone())), - Err(_) => Err(StatusCode::NOT_FOUND), - } -} - -// ── POST /projections/{name}/query ──────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ProjectionQueryRequest { - /// Seed node IDs for spreading activation. - pub seeds: Vec, - /// Query embedding for spreading activation. - pub query_embedding: Vec, - #[serde(default = "default_depth")] - pub max_depth: u8, - #[serde(default = "default_limit")] - pub limit: usize, -} - -fn default_depth() -> u8 { - 3 -} -fn default_limit() -> usize { - 20 -} - -pub async fn query_projection( - State(state): State>, - Path(name): Path, - Json(req): Json, -) -> Result, StatusCode> { - // Load schema - let schema = { - let reg = state - .projection_registry - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - match reg.get(&name) { - Ok(s) => s.clone(), - Err(_) => return Err(StatusCode::NOT_FOUND), - } - }; - - // Run spreading activation - let activated = { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - db.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - }; - - // Apply projection - let result = ProjectionEngine::project(&schema, &activated) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(result)) -} diff --git a/engrams/engram-server/src/routes/reasoning.rs b/engrams/engram-server/src/routes/reasoning.rs deleted file mode 100644 index 43cfe69..0000000 --- a/engrams/engram-server/src/routes/reasoning.rs +++ /dev/null @@ -1,177 +0,0 @@ -/// Reasoning API routes — graph-native inference over the Engram knowledge graph. -/// -/// POST /reason — evaluate a hypothesis -/// POST /reason/causal — find causal chains for a concept -/// POST /reason/contradictions — detect contradictions around a topic -use axum::{extract::State, http::StatusCode, Json}; -use engram_reasoning::{ - CausalDirection, Hypothesis, HypothesisType, ReasoningConfig, ReasoningEngine, - ReasoningResult, EvidenceChain, EvidenceNode, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::state::AppState; - -// ── POST /reason ────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ReasonRequest { - pub hypothesis: String, - pub hypothesis_type: HypothesisTypeParam, - pub embedding: Vec, - #[serde(default)] - pub config: ReasoningConfigParam, -} - -/// JSON-friendly version of HypothesisType (mirrors the enum for serde) -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum HypothesisTypeParam { - IsTrue, - WhatCauses, - HowTo, - WhatIs, - Compare, -} - -impl From for HypothesisType { - fn from(p: HypothesisTypeParam) -> Self { - match p { - HypothesisTypeParam::IsTrue => HypothesisType::IsTrue, - HypothesisTypeParam::WhatCauses => HypothesisType::WhatCauses, - HypothesisTypeParam::HowTo => HypothesisType::HowTo, - HypothesisTypeParam::WhatIs => HypothesisType::WhatIs, - HypothesisTypeParam::Compare => HypothesisType::Compare, - } - } -} - -#[derive(Deserialize, Default)] -pub struct ReasoningConfigParam { - pub max_depth: Option, - pub min_confidence: Option, - pub max_evidence_nodes: Option, - pub contradiction_threshold: Option, -} - -impl From for ReasoningConfig { - fn from(p: ReasoningConfigParam) -> Self { - let def = ReasoningConfig::default(); - ReasoningConfig { - max_depth: p.max_depth.unwrap_or(def.max_depth), - min_confidence: p.min_confidence.unwrap_or(def.min_confidence), - max_evidence_nodes: p.max_evidence_nodes.unwrap_or(def.max_evidence_nodes), - contradiction_threshold: p - .contradiction_threshold - .unwrap_or(def.contradiction_threshold), - } - } -} - -pub async fn reason( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let config: ReasoningConfig = req.config.into(); - let hypothesis = Hypothesis::new(req.hypothesis, req.embedding, req.hypothesis_type.into()); - - let db = state.db.clone(); - let mut engine = ReasoningEngine::new(db, config); - - let result = engine - .reason(&hypothesis) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(result)) -} - -// ── POST /reason/causal ─────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct CausalRequest { - pub concept_embedding: Vec, - #[serde(default = "default_causal_direction")] - pub direction: CausalDirectionParam, -} - -fn default_causal_direction() -> CausalDirectionParam { - CausalDirectionParam::Forward -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum CausalDirectionParam { - Forward, - Backward, - Both, -} - -impl From for CausalDirection { - fn from(p: CausalDirectionParam) -> Self { - match p { - CausalDirectionParam::Forward => CausalDirection::Forward, - CausalDirectionParam::Backward => CausalDirection::Backward, - CausalDirectionParam::Both => CausalDirection::Both, - } - } -} - -#[derive(Serialize)] -pub struct CausalResponse { - pub chains: Vec, -} - -pub async fn causal( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let db = state.db.clone(); - let mut engine = ReasoningEngine::with_default_config(db); - - let chains = engine - .causal_chain(&req.concept_embedding, req.direction.into()) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(CausalResponse { chains })) -} - -// ── POST /reason/contradictions ─────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ContradictionsRequest { - pub topic_embedding: Vec, -} - -#[derive(Serialize)] -pub struct ContradictionPair { - pub supporting: EvidenceNode, - pub refuting: EvidenceNode, -} - -#[derive(Serialize)] -pub struct ContradictionsResponse { - pub contradictions: Vec, -} - -pub async fn contradictions( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - let db = state.db.clone(); - let mut engine = ReasoningEngine::with_default_config(db); - - let pairs = engine - .find_contradictions(&req.topic_embedding) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(ContradictionsResponse { - contradictions: pairs - .into_iter() - .map(|(s, r)| ContradictionPair { - supporting: s, - refuting: r, - }) - .collect(), - })) -} diff --git a/engrams/engram-server/src/routes/swarm.rs b/engrams/engram-server/src/routes/swarm.rs deleted file mode 100644 index 62761f2..0000000 --- a/engrams/engram-server/src/routes/swarm.rs +++ /dev/null @@ -1,124 +0,0 @@ -/// Swarm routes — distributed activation across the peer network. -/// -/// POST /swarm/activate — SwarmActivateRequest → SwarmActivateResponse -/// GET /swarm/status — peer health check and last sync times -use axum::{ - extract::State, - http::StatusCode, - Json, -}; -use engram_sync::{ - client::SyncClient, merge_activation_results, Peer, PeerActivationResult, PeerStatus, - SerializableActivatedNode, SwarmActivateRequest, SwarmActivateResponse, -}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::state::AppState; - -/// POST /swarm/activate -/// -/// Runs spreading activation locally, then (if include_peers=true) fans out -/// to all trusted peers in parallel and returns merged results. -pub async fn swarm_activate( - State(state): State>, - Json(req): Json, -) -> Result, StatusCode> { - // Step 1: Run local activation. Lock db, compute, drop immediately. - let local_results: Vec = { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let activated = db - .activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - activated.into_iter().map(Into::into).collect() - // db lock released here - }; - - // Step 2: Snapshot peer list (lock, read, drop). - let (our_id, trusted_peers): (Uuid, Vec) = { - let engine = state.sync_engine.lock().await; - let id = engine.our_id(); - let peers = engine.list_peers().iter().filter(|p| p.trusted).cloned().collect(); - (id, peers) - // engine lock released here - }; - - // Step 3: Fan out to peers — no locks held across these awaits. - let mut peer_results: Vec = Vec::new(); - - if req.include_peers { - let mut handles = Vec::new(); - for peer in trusted_peers { - let seeds = req.seeds.clone(); - let embedding = req.query_embedding.clone(); - let max_depth = req.max_depth; - let limit = req.limit; - - handles.push(tokio::spawn(async move { - let client = SyncClient::new(peer.clone(), our_id); - match client.remote_activate(&seeds, &embedding, max_depth, limit).await { - Ok(results) => PeerActivationResult { - peer_id: peer.id, - peer_name: peer.name, - results, - error: None, - }, - Err(e) => PeerActivationResult { - peer_id: peer.id, - peer_name: peer.name, - results: Vec::new(), - error: Some(e.to_string()), - }, - } - })); - } - - for handle in handles { - if let Ok(result) = handle.await { - peer_results.push(result); - } - } - } - - // Step 4: Merge and return. - let merged = merge_activation_results(&local_results, &peer_results, req.limit); - - Ok(Json(SwarmActivateResponse { - local_results, - peer_results, - merged, - })) -} - -/// GET /swarm/status -/// -/// Returns peer list with reachability status and last sync times. -pub async fn swarm_status( - State(state): State>, -) -> Result>, StatusCode> { - // Snapshot peer list and our_id without holding the lock across awaits. - let (our_id, peers): (Uuid, Vec) = { - let engine = state.sync_engine.lock().await; - let id = engine.our_id(); - let peers = engine.list_peers().to_vec(); - (id, peers) - }; - - let mut statuses: Vec = Vec::new(); - for peer in peers { - use engram_core::types::now_ms; - let client = SyncClient::new(peer.clone(), our_id); - let reachable = client.pull_delta(now_ms()).await.is_ok(); - statuses.push(PeerStatus { - peer_id: peer.id, - peer_name: peer.name, - address: peer.address, - last_sync_at: peer.last_sync_at, - reachable, - sync_tiers: peer.sync_tiers, - trusted: peer.trusted, - }); - } - - Ok(Json(statuses)) -} diff --git a/engrams/engram-server/src/routes/sync.rs b/engrams/engram-server/src/routes/sync.rs deleted file mode 100644 index febf541..0000000 --- a/engrams/engram-server/src/routes/sync.rs +++ /dev/null @@ -1,133 +0,0 @@ -/// Sync routes — peer delta exchange and peer registry. -/// -/// All routes under /sync require Authorization: Bearer {api_key}. -/// -/// GET /sync/delta?since={ms}&peer_id={uuid} — generate delta for caller -/// POST /sync/push — receive delta from peer -/// POST /sync/peers — register a new peer -/// GET /sync/peers — list peers -/// DELETE /sync/peers/{id} — remove peer -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - Json, -}; -use engram_core::types::MemoryTier; -use engram_sync::{Peer, SyncDelta}; -use serde::Deserialize; -use std::sync::Arc; -use uuid::Uuid; - -use crate::state::AppState; - -// ── GET /sync/delta ─────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct DeltaParams { - pub since: Option, - pub peer_id: Option, -} - -pub async fn get_delta( - State(state): State>, - Query(params): Query, -) -> Result, StatusCode> { - let since = params.since.unwrap_or(0); - - // Determine which tiers to expose based on caller's peer_id - let tiers: Vec = { - let engine = state.sync_engine.lock().await; - if let Some(peer_id) = params.peer_id { - if let Some(peer) = engine.get_peer(peer_id) { - if peer.trusted { - peer.sync_tiers.clone() - } else { - vec![MemoryTier::Semantic] - } - } else { - vec![MemoryTier::Semantic] - } - } else { - vec![MemoryTier::Semantic] - } - // engine lock released here - }; - - let engine = state.sync_engine.lock().await; - let delta = engine - .generate_delta(since, &tiers) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(delta)) -} - -// ── POST /sync/push ─────────────────────────────────────────────────────────── - -pub async fn push_delta( - State(state): State>, - Json(delta): Json, -) -> Result { - // Accepted tiers for incoming pushes (Semantic and Procedural by default) - let accepted_tiers = vec![MemoryTier::Semantic, MemoryTier::Procedural]; - - // Apply the delta using the DB handle directly (no async needed) - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - // Tombstones - for id in &delta.tombstones { - let _ = db.delete_node(*id); - } - - // Nodes - for node in delta.nodes { - if !accepted_tiers.contains(&node.tier) { - continue; - } - if db.get_node(node.id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.is_some() { - continue; - } - db.put_node(node).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - // Edges - for edge in delta.edges { - let from_ok = db.get_node(edge.from_id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .is_some(); - let to_ok = db.get_node(edge.to_id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .is_some(); - if from_ok && to_ok { - let _ = db.put_edge(edge); - } - } - - Ok(StatusCode::OK) -} - -// ── Peer registry ───────────────────────────────────────────────────────────── - -pub async fn list_peers( - State(state): State>, -) -> Result>, StatusCode> { - let engine = state.sync_engine.lock().await; - Ok(Json(engine.list_peers().to_vec())) -} - -pub async fn register_peer( - State(state): State>, - Json(peer): Json, -) -> Result { - let mut engine = state.sync_engine.lock().await; - engine.add_peer(peer); - Ok(StatusCode::CREATED) -} - -pub async fn delete_peer( - State(state): State>, - Path(id): Path, -) -> Result { - let mut engine = state.sync_engine.lock().await; - engine.remove_peer(id); - Ok(StatusCode::NO_CONTENT) -} diff --git a/engrams/engram-server/src/routes/tx.rs b/engrams/engram-server/src/routes/tx.rs deleted file mode 100644 index 2065c5f..0000000 --- a/engrams/engram-server/src/routes/tx.rs +++ /dev/null @@ -1,109 +0,0 @@ -/// Transaction API routes. -/// -/// POST /tx/apply — apply a command -/// POST /tx/rollback/{id} — roll back a command -/// GET /tx/history?since={ms} — command history -/// GET /tx/chain/{id} — causal chain for a command -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - Json, -}; -use engram_tx::{Command, CommandResult}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::state::AppState; - -// ── POST /tx/apply ──────────────────────────────────────────────────────────── - -pub async fn tx_apply( - State(state): State>, - Json(cmd): Json, -) -> Result, StatusCode> { - let mut engine = state - .tx_engine - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let result = engine - .apply(cmd) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(result)) -} - -// ── POST /tx/rollback/{command_id} ─────────────────────────────────────────── - -#[derive(Serialize)] -pub struct RollbackResponse { - pub rollback_command_id: Uuid, - pub status: String, -} - -pub async fn tx_rollback( - State(state): State>, - Path(command_id): Path, -) -> Result, StatusCode> { - let mut engine = state - .tx_engine - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let rb = engine - .rollback(command_id) - .map_err(|e| { - tracing::warn!("rollback failed: {}", e); - StatusCode::BAD_REQUEST - })?; - Ok(Json(RollbackResponse { - rollback_command_id: rb.id, - status: format!("{:?}", rb.status), - })) -} - -// ── GET /tx/history ─────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct HistoryParams { - pub since: Option, -} - -#[derive(Serialize)] -pub struct HistoryResponse { - pub commands: Vec, -} - -pub async fn tx_history( - State(state): State>, - Query(params): Query, -) -> Result, StatusCode> { - let engine = state - .tx_engine - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let since = params.since.unwrap_or(0); - let commands = engine - .history(since) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(HistoryResponse { commands })) -} - -// ── GET /tx/chain/{command_id} ──────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct CausalChainResponse { - pub chain: Vec, -} - -pub async fn tx_causal_chain( - State(state): State>, - Path(command_id): Path, -) -> Result, StatusCode> { - let engine = state - .tx_engine - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let chain = engine - .causal_chain(command_id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(CausalChainResponse { chain })) -} diff --git a/engrams/engram-server/src/state.rs b/engrams/engram-server/src/state.rs deleted file mode 100644 index c56f280..0000000 --- a/engrams/engram-server/src/state.rs +++ /dev/null @@ -1,19 +0,0 @@ -/// Shared application state for all request handlers. -use engram_core::EngramDb; -use engram_projection::registry::ProjectionRegistry; -use engram_sync::SyncEngine; -use engram_tx::TransactionEngine; -use std::sync::{Arc, Mutex}; - -pub struct AppState { - /// Local database — uses std::sync::Mutex (sync ops only, fast) - pub db: Arc>, - /// Sync engine — uses tokio::sync::Mutex so it can be held across .await - pub sync_engine: Arc>, - /// API key used to authenticate incoming sync requests - pub api_key: String, - /// Projection registry — named schema views over the activation surface - pub projection_registry: Arc>, - /// Transaction engine — append-only command log with rollback support - pub tx_engine: Arc>, -} diff --git a/engrams/engram-sync/Cargo.toml b/engrams/engram-sync/Cargo.toml deleted file mode 100644 index 8952193..0000000 --- a/engrams/engram-sync/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "engram-sync" -version = "0.1.0" -edition = "2021" -description = "Swarm memory sync layer for Engram — peer delta sync and distributed activation" -license = "MIT" - -[lib] -name = "engram_sync" -path = "src/lib.rs" - -[dependencies] -engram-core = { path = "../engram-core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -reqwest = { version = "0.12", features = ["json"] } -tokio = { version = "1", features = ["full"] } -anyhow = "1" -thiserror = "1" - -[dev-dependencies] -tempfile = "3" -tokio = { version = "1", features = ["full", "test-util"] } diff --git a/engrams/engram-sync/src/client.rs b/engrams/engram-sync/src/client.rs deleted file mode 100644 index 4a1bca1..0000000 --- a/engrams/engram-sync/src/client.rs +++ /dev/null @@ -1,122 +0,0 @@ -/// HTTP client for talking to a remote Engram peer. -/// -/// All peer-to-peer communication goes through this client. -/// Authentication is via `Authorization: Bearer {api_key}` on every request. -use crate::types::{Peer, SerializableActivatedNode, SyncDelta}; -use anyhow::Context; -use uuid::Uuid; - -pub struct SyncClient { - peer: Peer, - http: reqwest::Client, - our_id: Uuid, -} - -impl SyncClient { - pub fn new(peer: Peer, our_id: Uuid) -> Self { - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("failed to build reqwest client"); - Self { peer, http, our_id } - } - - /// Pull a delta from the remote peer containing everything since `since` (Unix ms). - /// - /// GET {address}/sync/delta?since={since}&peer_id={our_id} - pub async fn pull_delta(&self, since: i64) -> anyhow::Result { - let url = format!( - "{}/sync/delta?since={}&peer_id={}", - self.peer.address, since, self.our_id - ); - let resp = self - .http - .get(&url) - .header("Authorization", format!("Bearer {}", self.peer.api_key)) - .send() - .await - .context("pull_delta: request failed")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("pull_delta: peer returned {}: {}", status, body); - } - - resp.json::() - .await - .context("pull_delta: failed to decode response") - } - - /// Push our delta to the remote peer. - /// - /// POST {address}/sync/push - pub async fn push_delta(&self, delta: &SyncDelta) -> anyhow::Result<()> { - let url = format!("{}/sync/push", self.peer.address); - let resp = self - .http - .post(&url) - .header("Authorization", format!("Bearer {}", self.peer.api_key)) - .json(delta) - .send() - .await - .context("push_delta: request failed")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("push_delta: peer returned {}: {}", status, body); - } - - Ok(()) - } - - /// Fan spreading activation out to a remote peer. - /// - /// POST {address}/swarm/activate — the remote peer runs activation locally - /// (include_peers=false so it does not fan out further, preventing cycles). - pub async fn remote_activate( - &self, - seeds: &[Uuid], - query_embedding: &[f32], - max_depth: u8, - limit: usize, - ) -> anyhow::Result> { - use crate::types::SwarmActivateRequest; - - let url = format!("{}/swarm/activate", self.peer.address); - let req = SwarmActivateRequest { - seeds: seeds.to_vec(), - query_embedding: query_embedding.to_vec(), - max_depth, - limit, - include_peers: false, // no further fan-out — prevents cycles - }; - - let resp = self - .http - .post(&url) - .header("Authorization", format!("Bearer {}", self.peer.api_key)) - .json(&req) - .send() - .await - .context("remote_activate: request failed")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("remote_activate: peer returned {}: {}", status, body); - } - - // The remote returns a SwarmActivateResponse; we only want local_results - let response: crate::types::SwarmActivateResponse = resp - .json() - .await - .context("remote_activate: failed to decode response")?; - Ok(response.local_results) - } - - pub fn peer(&self) -> &Peer { - &self.peer - } -} diff --git a/engrams/engram-sync/src/engine.rs b/engrams/engram-sync/src/engine.rs deleted file mode 100644 index abeaacb..0000000 --- a/engrams/engram-sync/src/engine.rs +++ /dev/null @@ -1,423 +0,0 @@ -/// SyncEngine — orchestrates peer sync and swarm activation. -/// -/// The engine holds a list of known peers, a handle to the local database, -/// and owns our identity (UUID + API key). It drives two workflows: -/// -/// 1. **Delta sync**: periodic pull-then-push with each peer, filtering by -/// tier allowlist and skipping nodes we already have. -/// -/// 2. **Swarm activation**: run spreading activation locally, then fan out -/// to all trusted peers in parallel, and merge results by strength. -use crate::client::SyncClient; -use crate::types::{ - MergedActivatedNode, Peer, PeerActivationResult, PeerStatus, PeerSyncResult, - SerializableActivatedNode, SyncConfig, SyncDelta, SyncReport, SwarmActivateRequest, - SwarmActivateResponse, -}; -use engram_core::types::{now_ms, MemoryTier}; -use engram_core::EngramDb; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; - -pub struct SyncEngine { - db: Arc>, - peers: Vec, - our_id: Uuid, - our_name: String, - api_key: String, - default_sync_tiers: Vec, -} - -impl SyncEngine { - pub fn new(db: Arc>, config: SyncConfig) -> Self { - Self { - db, - peers: Vec::new(), - our_id: config.our_id, - our_name: config.our_name, - api_key: config.api_key, - default_sync_tiers: config.default_sync_tiers, - } - } - - // ── Peer registry ───────────────────────────────────────────────────────── - - pub fn add_peer(&mut self, peer: Peer) { - // Replace if already registered - self.peers.retain(|p| p.id != peer.id); - self.peers.push(peer); - } - - pub fn remove_peer(&mut self, peer_id: Uuid) { - self.peers.retain(|p| p.id != peer_id); - } - - pub fn list_peers(&self) -> &[Peer] { - &self.peers - } - - pub fn get_peer(&self, peer_id: Uuid) -> Option<&Peer> { - self.peers.iter().find(|p| p.id == peer_id) - } - - pub fn our_id(&self) -> Uuid { - self.our_id - } - - pub fn our_name(&self) -> &str { - &self.our_name - } - - pub fn api_key(&self) -> &str { - &self.api_key - } - - // ── Full sync cycle ─────────────────────────────────────────────────────── - - /// Sync with every registered peer. Returns a report summarising what moved. - pub async fn sync_all(&mut self) -> SyncReport { - let mut report = SyncReport { - peers_synced: 0, - nodes_received: 0, - nodes_sent: 0, - errors: Vec::new(), - }; - - // Clone the peer list so we can mutate self afterwards - let peers: Vec = self.peers.clone(); - for peer in peers { - match self.sync_peer(&peer).await { - Ok(result) => { - report.peers_synced += 1; - report.nodes_received += result.nodes_received; - report.nodes_sent += result.nodes_sent; - // Update last_sync_at - if let Some(p) = self.peers.iter_mut().find(|p| p.id == peer.id) { - p.last_sync_at = now_ms(); - } - } - Err(e) => { - report.errors.push(format!("peer {}: {}", peer.name, e)); - } - } - } - - report - } - - /// Sync a single peer: pull delta, apply it, then push our delta. - pub async fn sync_peer(&self, peer: &Peer) -> anyhow::Result { - let client = SyncClient::new(peer.clone(), self.our_id); - let tiers = effective_tiers(peer, &self.default_sync_tiers); - - // Pull: get everything from the peer since their last known sync time - let remote_delta = client.pull_delta(peer.last_sync_at).await?; - let nodes_received = self.apply_delta(remote_delta, &tiers).await?; - - // Push: send everything we have that the peer hasn't seen - let our_delta = self.generate_delta(peer.last_sync_at, &tiers)?; - let nodes_sent = our_delta.nodes.len(); - client.push_delta(&our_delta).await?; - - Ok(PeerSyncResult { - peer_id: peer.id, - nodes_received, - nodes_sent, - }) - } - - // ── Delta generation ────────────────────────────────────────────────────── - - /// Build a delta containing all nodes/edges modified since `since` - /// that belong to one of the allowed `tiers`. - pub fn generate_delta( - &self, - since: i64, - tiers: &[MemoryTier], - ) -> anyhow::Result { - let db = self - .db - .lock() - .map_err(|_| anyhow::anyhow!("db lock poisoned"))?; - - // Scan all nodes; filter by tier and modification time - let all_nodes = db.scan_nodes()?; - let nodes: Vec<_> = all_nodes - .into_iter() - .filter(|n| tiers.contains(&n.tier) && n.last_activated >= since) - .collect(); - - // Edges: include all edges between nodes in our set - let node_ids: std::collections::HashSet = nodes.iter().map(|n| n.id).collect(); - let all_edges = db.scan_edges()?; - let edges: Vec<_> = all_edges - .into_iter() - .filter(|e| node_ids.contains(&e.from_id) && node_ids.contains(&e.to_id)) - .collect(); - - Ok(SyncDelta { - peer_id: self.our_id, - since, - nodes, - edges, - tombstones: Vec::new(), // tombstone tracking requires a separate log; not yet implemented - generated_at: now_ms(), - }) - } - - // ── Delta application ───────────────────────────────────────────────────── - - /// Merge an incoming delta into the local database. - /// - /// - Nodes/edges we already have (same UUID) are skipped — local wins. - /// - Tombstones cause deletion. - /// - Only nodes in the allowed tiers are accepted. - /// - /// Returns the number of nodes actually written. - pub async fn apply_delta( - &self, - delta: SyncDelta, - allowed_tiers: &[MemoryTier], - ) -> anyhow::Result { - let db = self - .db - .lock() - .map_err(|_| anyhow::anyhow!("db lock poisoned"))?; - - let mut written = 0usize; - - // Apply tombstones first - for id in &delta.tombstones { - let _ = db.delete_node(*id); - } - - // Merge nodes - for node in delta.nodes { - if !allowed_tiers.contains(&node.tier) { - continue; - } - // Skip if we already have this UUID - if db.get_node(node.id)?.is_some() { - continue; - } - db.put_node(node)?; - written += 1; - } - - // Merge edges (if both endpoints exist) - for edge in delta.edges { - let from_exists = db.get_node(edge.from_id)?.is_some(); - let to_exists = db.get_node(edge.to_id)?.is_some(); - if from_exists && to_exists { - db.put_edge(edge)?; - } - } - - Ok(written) - } - - // ── Swarm activation ────────────────────────────────────────────────────── - - /// Run spreading activation locally, then fan out to all trusted peers, - /// and merge all results into a unified ranked list. - pub async fn swarm_activate( - &self, - req: SwarmActivateRequest, - ) -> anyhow::Result { - // Local activation - let local_results: Vec = { - let db = self - .db - .lock() - .map_err(|_| anyhow::anyhow!("db lock poisoned"))?; - let activated = db.activate( - &req.seeds, - &req.query_embedding, - req.max_depth, - req.limit, - )?; - activated.into_iter().map(Into::into).collect() - }; - - let mut peer_results: Vec = Vec::new(); - - if req.include_peers { - // Fan out to all trusted peers in parallel - let trusted_peers: Vec = self - .peers - .iter() - .filter(|p| p.trusted) - .cloned() - .collect(); - - let mut handles = Vec::new(); - for peer in trusted_peers { - let seeds = req.seeds.clone(); - let embedding = req.query_embedding.clone(); - let max_depth = req.max_depth; - let limit = req.limit; - let our_id = self.our_id; - - handles.push(tokio::spawn(async move { - let client = SyncClient::new(peer.clone(), our_id); - match client - .remote_activate(&seeds, &embedding, max_depth, limit) - .await - { - Ok(results) => PeerActivationResult { - peer_id: peer.id, - peer_name: peer.name, - results, - error: None, - }, - Err(e) => PeerActivationResult { - peer_id: peer.id, - peer_name: peer.name, - results: Vec::new(), - error: Some(e.to_string()), - }, - } - })); - } - - for handle in handles { - match handle.await { - Ok(result) => peer_results.push(result), - Err(e) => peer_results.push(PeerActivationResult { - peer_id: Uuid::nil(), - peer_name: "unknown".into(), - results: Vec::new(), - error: Some(format!("task error: {}", e)), - }), - } - } - } - - let merged = merge_activation_results(&local_results, &peer_results, req.limit); - - Ok(SwarmActivateResponse { - local_results, - peer_results, - merged, - }) - } - - // ── Peer health check ───────────────────────────────────────────────────── - - /// Check which peers are reachable and return their status. - pub async fn peer_statuses(&self) -> Vec { - let mut statuses = Vec::new(); - for peer in &self.peers { - let client = SyncClient::new(peer.clone(), self.our_id); - // We attempt a health check by pulling an empty delta (since=now) - let reachable = client.pull_delta(now_ms()).await.is_ok(); - statuses.push(PeerStatus { - peer_id: peer.id, - peer_name: peer.name.clone(), - address: peer.address.clone(), - last_sync_at: peer.last_sync_at, - reachable, - sync_tiers: peer.sync_tiers.clone(), - trusted: peer.trusted, - }); - } - statuses - } -} - -// ── Merge logic ─────────────────────────────────────────────────────────────── - -/// Merge local and peer activation results into a unified ranked list. -/// -/// Deduplication: two nodes are considered duplicates if they have the same UUID. -/// When duplicates occur, the one with the highest activation strength is kept. -/// The merged list is sorted by activation_strength descending and truncated to `limit`. -pub fn merge_activation_results( - local: &[SerializableActivatedNode], - peer_results: &[PeerActivationResult], - limit: usize, -) -> Vec { - use std::collections::HashMap; - - // (uuid -> MergedActivatedNode) — we keep the strongest instance of each node - let mut map: HashMap = HashMap::new(); - - // Process local results first (source_peer = None) - for item in local { - let id = item.node.id; - let candidate = MergedActivatedNode { - content: String::from_utf8_lossy(&item.node.content).to_string(), - node_type: item.node.node_type.clone(), - tier: item.node.tier.clone(), - activation_strength: item.activation_strength, - source_peer: None, - hops: item.hops, - node: item.node.clone(), - }; - map.entry(id) - .and_modify(|existing| { - if item.activation_strength > existing.activation_strength { - *existing = candidate.clone(); - } - }) - .or_insert(candidate); - } - - // Process peer results - for peer_result in peer_results { - if peer_result.error.is_some() { - continue; - } - for item in &peer_result.results { - let id = item.node.id; - let candidate = MergedActivatedNode { - content: String::from_utf8_lossy(&item.node.content).to_string(), - node_type: item.node.node_type.clone(), - tier: item.node.tier.clone(), - activation_strength: item.activation_strength, - source_peer: Some(peer_result.peer_id), - hops: item.hops, - node: item.node.clone(), - }; - map.entry(id) - .and_modify(|existing| { - if item.activation_strength > existing.activation_strength { - *existing = candidate.clone(); - } - }) - .or_insert(candidate); - } - } - - // Sort by strength descending, take top N - let mut merged: Vec = map.into_values().collect(); - merged.sort_by(|a, b| { - b.activation_strength - .partial_cmp(&a.activation_strength) - .unwrap_or(std::cmp::Ordering::Equal) - }); - merged.truncate(limit); - merged -} - -// ── Tier helpers ────────────────────────────────────────────────────────────── - -/// Compute the effective sync tiers for a peer — intersection of the peer's -/// own allowlist and our defaults. Untrusted peers are further restricted -/// to Semantic only. -fn effective_tiers<'a>(peer: &Peer, defaults: &'a [MemoryTier]) -> Vec { - if !peer.trusted { - // Untrusted: Semantic only, regardless of configuration - return vec![MemoryTier::Semantic]; - } - if peer.sync_tiers.is_empty() { - defaults.to_vec() - } else { - // Intersection: only tiers that both us and the peer agree on - defaults - .iter() - .filter(|t| peer.sync_tiers.contains(t)) - .cloned() - .collect() - } -} diff --git a/engrams/engram-sync/src/lib.rs b/engrams/engram-sync/src/lib.rs deleted file mode 100644 index b68be7d..0000000 --- a/engrams/engram-sync/src/lib.rs +++ /dev/null @@ -1,265 +0,0 @@ -/// Engram Sync — swarm memory protocol for distributed Engram instances. -/// -/// This crate turns Engram from a local-first database into a distributed -/// swarm memory protocol. Multiple independent Engram instances (peers) can -/// share memory across each other using delta sync and can fan spreading -/// activation out across the swarm, merging results by strength. -/// -/// # Architecture -/// -/// ```text -/// [Neuron-A Engram] <-> sync <-> [Neuron-B Engram] -/// | -/// [Neuron-C Engram] -/// -/// Swarm activation: seed on A → propagate locally → fan-out to B and C -/// → merge all results → unified ranked response -/// ``` -/// -/// # Protocol -/// -/// - Each peer is local and authoritative. There is no central server. -/// - Peers sync via delta exchange: "give me everything since timestamp T". -/// - Only configured memory tiers flow between peers (Semantic by default; -/// Episodic and Working are private unless explicitly enabled). -/// - Trusted peers get the full configured tier set; untrusted peers get -/// Semantic only. -/// - Swarm activation fans out to all trusted peers in parallel, deduplicates -/// by UUID (keeping strongest activation), and re-ranks. - -pub mod client; -pub mod engine; -pub mod types; - -// Public surface -pub use engine::{merge_activation_results, SyncEngine}; -pub use types::{ - MergedActivatedNode, Peer, PeerActivationResult, PeerStatus, PeerSyncResult, - SerializableActivatedNode, SyncConfig, SyncDelta, SyncReport, SwarmActivateRequest, - SwarmActivateResponse, -}; - -#[cfg(test)] -mod tests { - use super::*; - use engram_core::types::{MemoryTier, Node, NodeType}; - use uuid::Uuid; - - fn make_node(tier: MemoryTier) -> Node { - Node::new( - NodeType::Concept, - vec![0.1, 0.2, 0.3], - b"test content".to_vec(), - tier, - 0.5, - ) - } - - fn make_activated(node: Node, strength: f32, hops: u8) -> SerializableActivatedNode { - SerializableActivatedNode { - node, - activation_strength: strength, - hops, - } - } - - // ── merge_activation_results tests ─────────────────────────────────────── - - #[test] - fn merge_empty() { - let merged = merge_activation_results(&[], &[], 10); - assert!(merged.is_empty()); - } - - #[test] - fn merge_local_only() { - let node = make_node(MemoryTier::Semantic); - let node_id = node.id; - let local = vec![make_activated(node, 0.8, 1)]; - let merged = merge_activation_results(&local, &[], 10); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].node.id, node_id); - assert!(merged[0].source_peer.is_none(), "local nodes have no source_peer"); - assert!((merged[0].activation_strength - 0.8).abs() < f32::EPSILON); - } - - #[test] - fn merge_peer_result_included() { - let local: Vec = vec![]; - let node = make_node(MemoryTier::Semantic); - let peer_id = Uuid::new_v4(); - let peer_results = vec![PeerActivationResult { - peer_id, - peer_name: "peer-a".into(), - results: vec![make_activated(node, 0.6, 2)], - error: None, - }]; - let merged = merge_activation_results(&local, &peer_results, 10); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].source_peer, Some(peer_id)); - } - - #[test] - fn merge_deduplicates_by_uuid_keeps_strongest() { - let node = make_node(MemoryTier::Semantic); - let id = node.id; - - // Same node appears locally at 0.4 and from a peer at 0.9 - let local = vec![make_activated(node.clone(), 0.4, 1)]; - let peer_id = Uuid::new_v4(); - let peer_results = vec![PeerActivationResult { - peer_id, - peer_name: "peer-a".into(), - results: vec![make_activated(node, 0.9, 1)], - error: None, - }]; - - let merged = merge_activation_results(&local, &peer_results, 10); - // Should be deduplicated to 1 result - assert_eq!(merged.len(), 1); - // The stronger version (0.9, from peer) should win - assert!((merged[0].activation_strength - 0.9).abs() < f32::EPSILON); - assert_eq!(merged[0].source_peer, Some(peer_id)); - } - - #[test] - fn merge_respects_limit() { - let local: Vec = (0..20) - .map(|i| make_activated(make_node(MemoryTier::Semantic), i as f32 / 20.0, 1)) - .collect(); - let merged = merge_activation_results(&local, &[], 5); - assert_eq!(merged.len(), 5, "limit must be respected"); - } - - #[test] - fn merge_sorted_by_strength_descending() { - let strengths = vec![0.3f32, 0.9, 0.1, 0.7, 0.5]; - let local: Vec = strengths - .iter() - .map(|&s| make_activated(make_node(MemoryTier::Semantic), s, 1)) - .collect(); - let merged = merge_activation_results(&local, &[], 10); - let result_strengths: Vec = merged.iter().map(|m| m.activation_strength).collect(); - // Should be in descending order - for window in result_strengths.windows(2) { - assert!(window[0] >= window[1], "results must be sorted descending"); - } - } - - #[test] - fn merge_skips_errored_peers() { - let peer_results = vec![PeerActivationResult { - peer_id: Uuid::new_v4(), - peer_name: "failed-peer".into(), - results: vec![make_activated(make_node(MemoryTier::Semantic), 0.9, 1)], - error: Some("connection refused".into()), - }]; - let merged = merge_activation_results(&[], &peer_results, 10); - // Errored peers should be excluded - assert!(merged.is_empty()); - } - - // ── SyncDelta serialization ─────────────────────────────────────────────── - - #[test] - fn sync_delta_roundtrips_json() { - let delta = SyncDelta { - peer_id: Uuid::new_v4(), - since: 1000, - nodes: vec![make_node(MemoryTier::Semantic)], - edges: vec![], - tombstones: vec![Uuid::new_v4()], - generated_at: 2000, - }; - let json = serde_json::to_string(&delta).expect("serialize delta"); - let decoded: SyncDelta = serde_json::from_str(&json).expect("deserialize delta"); - assert_eq!(delta.peer_id, decoded.peer_id); - assert_eq!(delta.since, decoded.since); - assert_eq!(delta.nodes.len(), decoded.nodes.len()); - assert_eq!(delta.tombstones.len(), decoded.tombstones.len()); - } - - // ── SyncEngine peer management ──────────────────────────────────────────── - - #[test] - fn engine_add_remove_peer() { - use engram_core::EngramDb; - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap())); - let config = SyncConfig::default(); - let mut engine = SyncEngine::new(db, config); - - assert_eq!(engine.list_peers().len(), 0); - - let peer = Peer { - id: Uuid::new_v4(), - name: "test-peer".into(), - address: "http://localhost:9999".into(), - api_key: "secret".into(), - sync_tiers: vec![MemoryTier::Semantic], - last_sync_at: 0, - trusted: true, - }; - let peer_id = peer.id; - engine.add_peer(peer); - assert_eq!(engine.list_peers().len(), 1); - - engine.remove_peer(peer_id); - assert_eq!(engine.list_peers().len(), 0); - } - - #[test] - fn engine_add_peer_replaces_existing() { - use engram_core::EngramDb; - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap())); - let mut engine = SyncEngine::new(db, SyncConfig::default()); - - let id = Uuid::new_v4(); - for i in 0..3 { - engine.add_peer(Peer { - id, - name: format!("peer-v{}", i), - address: "http://localhost:1234".into(), - api_key: "k".into(), - sync_tiers: vec![], - last_sync_at: 0, - trusted: false, - }); - } - // Should still be just one peer (latest version) - assert_eq!(engine.list_peers().len(), 1); - assert_eq!(engine.list_peers()[0].name, "peer-v2"); - } - - // ── generate_delta ──────────────────────────────────────────────────────── - - #[test] - fn generate_delta_filters_by_tier() { - use engram_core::types::MemoryTier; - use engram_core::EngramDb; - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap())); - - // Insert nodes in different tiers - { - let db_locked = db.lock().unwrap(); - db_locked.put_node(make_node(MemoryTier::Semantic)).unwrap(); - db_locked.put_node(make_node(MemoryTier::Episodic)).unwrap(); - db_locked.put_node(make_node(MemoryTier::Working)).unwrap(); - } - - let engine = SyncEngine::new(db, SyncConfig::default()); - - // Only request Semantic tier - let delta = engine.generate_delta(0, &[MemoryTier::Semantic]).unwrap(); - assert_eq!(delta.nodes.len(), 1, "only Semantic node should be in delta"); - assert!(delta.nodes[0].tier == MemoryTier::Semantic); - } -} diff --git a/engrams/engram-sync/src/types.rs b/engrams/engram-sync/src/types.rs deleted file mode 100644 index b0bbb4e..0000000 --- a/engrams/engram-sync/src/types.rs +++ /dev/null @@ -1,166 +0,0 @@ -use engram_core::types::{ActivatedNode, Edge, MemoryTier, Node, NodeType}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// A remote peer that this Engram instance syncs with. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Peer { - /// Stable unique identity for this peer - pub id: Uuid, - /// Human-readable name (e.g. "neuron-will", "neuron-sarah") - pub name: String, - /// Base URL of the peer's Engram server (e.g. "https://engram.neurontechnologies.ai") - pub address: String, - /// Shared secret used in the Authorization: Bearer header - pub api_key: String, - /// Which memory tiers are allowed to flow to/from this peer. - /// Semantic by default — Episodic is private unless explicitly opted in. - pub sync_tiers: Vec, - /// Unix milliseconds of the last successful sync. 0 if never synced. - pub last_sync_at: i64, - /// Trusted peers get all configured tiers; untrusted peers get Semantic only. - pub trusted: bool, -} - -/// An incremental change set — everything that changed since a given timestamp. -/// Exchanged between peers during sync. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncDelta { - /// UUID of the peer that generated this delta - pub peer_id: Uuid, - /// All nodes modified or added after this Unix ms timestamp - pub since: i64, - /// Nodes added or modified since `since` - pub nodes: Vec, - /// Edges added or modified since `since` - pub edges: Vec, - /// Node IDs that were deleted (tombstones) — receivers should remove them - pub tombstones: Vec, - /// When this delta was generated - pub generated_at: i64, -} - -/// Request to fan spreading activation out across the swarm. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SwarmActivateRequest { - /// Seed node IDs to start activation from - pub seeds: Vec, - /// Query embedding for semantic scoring - pub query_embedding: Vec, - /// Maximum graph hops per peer - pub max_depth: u8, - /// Maximum results to return per peer (before merge) - pub limit: usize, - /// If true, fan out to all trusted peers and merge results - pub include_peers: bool, -} - -/// Response from a swarm activation — local results, per-peer results, and -/// the unified merged ranking across all sources. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SwarmActivateResponse { - pub local_results: Vec, - pub peer_results: Vec, - /// Deduplicated, re-ranked unified results from all sources - pub merged: Vec, -} - -/// ActivatedNode serializable form (ActivatedNode in engram-core is not Serialize). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializableActivatedNode { - pub node: Node, - pub activation_strength: f32, - pub hops: u8, -} - -impl From for SerializableActivatedNode { - fn from(a: ActivatedNode) -> Self { - Self { - node: a.node, - activation_strength: a.activation_strength, - hops: a.hops, - } - } -} - -/// Activation results from a single remote peer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PeerActivationResult { - pub peer_id: Uuid, - pub peer_name: String, - /// Successfully retrieved results (empty on error) - pub results: Vec, - /// Set if the peer request failed - pub error: Option, -} - -/// A node in the merged swarm result set — annotated with its origin peer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MergedActivatedNode { - pub content: String, - pub node_type: NodeType, - pub tier: MemoryTier, - /// Composite activation strength after merging - pub activation_strength: f32, - /// None = local, Some(uuid) = from that peer - pub source_peer: Option, - pub hops: u8, - /// Full node for callers who need it - pub node: Node, -} - -/// Aggregate report from a full sync cycle. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncReport { - pub peers_synced: usize, - pub nodes_received: usize, - pub nodes_sent: usize, - pub errors: Vec, -} - -/// Result from syncing a single peer. -#[derive(Debug, Clone)] -pub struct PeerSyncResult { - pub peer_id: Uuid, - pub nodes_received: usize, - pub nodes_sent: usize, -} - -/// Configuration for the sync engine. -#[derive(Debug, Clone)] -pub struct SyncConfig { - /// This instance's stable UUID - pub our_id: Uuid, - /// Human name for this instance - pub our_name: String, - /// API key peers use to authenticate with us - pub api_key: String, - /// Which tiers to sync by default (peers may also restrict this) - pub default_sync_tiers: Vec, - /// How often to run background sync, in seconds. Default: 300 (5 min) - pub sync_interval_secs: u64, -} - -impl Default for SyncConfig { - fn default() -> Self { - Self { - our_id: Uuid::new_v4(), - our_name: "engram-local".to_string(), - api_key: Uuid::new_v4().to_string(), - default_sync_tiers: vec![MemoryTier::Semantic], - sync_interval_secs: 300, - } - } -} - -/// Swarm peer health info for the /swarm/status endpoint. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PeerStatus { - pub peer_id: Uuid, - pub peer_name: String, - pub address: String, - pub last_sync_at: i64, - pub reachable: bool, - pub sync_tiers: Vec, - pub trusted: bool, -} diff --git a/engrams/engram-tx/Cargo.toml b/engrams/engram-tx/Cargo.toml deleted file mode 100644 index 55ac1f3..0000000 --- a/engrams/engram-tx/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "engram-tx" -version = "0.1.0" -edition = "2021" -description = "Command pattern + rollback-of-rollback transaction engine for Engram" -license = "MIT" - -[dependencies] -engram-core = { path = "../engram-core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -thiserror = "1" -sled = "0.34" - -[dev-dependencies] -tempfile = "3" diff --git a/engrams/engram-tx/src/command.rs b/engrams/engram-tx/src/command.rs deleted file mode 100644 index 54eda16..0000000 --- a/engrams/engram-tx/src/command.rs +++ /dev/null @@ -1,166 +0,0 @@ -/// Command — the first-class mutation unit of Engram's transaction system. -/// -/// A command is an immutable record of intent. Once created, its ID, type, -/// idempotency key, and causal parent never change. Status and conflict fields -/// are the only mutable parts, updated as the command moves through its lifecycle. -use engram_core::types::MemoryTier; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use uuid::Uuid; - -/// The operation a command performs. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum CommandType { - /// Create a new node. `payload` contains the node's fields. - CreateNode, - /// Update an existing node's content/metadata. `payload` contains the new fields. - UpdateNode, - /// Delete a node by UUID. - DeleteNode, - /// Create an edge between two nodes. - CreateEdge, - /// Delete an edge by its from/to pair. - DeleteEdge, - /// Update a node's salience score. - UpdateSalience, - /// Batch import of many nodes/edges. - BulkImport, - /// Roll back a previously applied command. The UUID is the target command's ID. - Rollback(Uuid), -} - -/// Lifecycle status of a command. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum CommandStatus { - /// Created but not yet applied. - Pending, - /// Successfully applied to the database. - Applied, - /// Rolled back (a subsequent Rollback command was applied). - RolledBack, - /// Applied but conflicted with another command; conflict details in the log. - Conflicted, -} - -/// A command in Engram's append-only mutation log. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Command { - /// Stable unique identifier for this command. - pub id: Uuid, - /// The operation this command performs. - pub command_type: CommandType, - /// The forward operation data — what to do. - pub payload: Value, - /// The inverse operation data — computed eagerly at command creation time. - /// This is the "undo" data, available even if the graph has changed since. - pub inverse_payload: Value, - /// Dedup key. If a command with this idempotency key has already been applied, - /// the new command is a no-op. Prevents double-application in distributed sync. - pub idempotency_key: String, - /// The command that caused this one, if any (forms the causal DAG). - pub causal_parent: Option, - /// Unix milliseconds when this command was created. - pub timestamp_ms: i64, - /// Current lifecycle status. - pub status: CommandStatus, - /// Which peer originated this command (for conflict resolution). - pub peer_id: Option, - /// If conflicted, a human-readable description of the conflict. - pub conflict_note: Option, -} - -impl Command { - /// Create a new pending command. - pub fn new( - command_type: CommandType, - payload: Value, - inverse_payload: Value, - idempotency_key: impl Into, - causal_parent: Option, - peer_id: Option, - ) -> Self { - Self { - id: Uuid::new_v4(), - command_type, - payload, - inverse_payload, - idempotency_key: idempotency_key.into(), - causal_parent, - timestamp_ms: engram_core::types::now_ms(), - status: CommandStatus::Pending, - peer_id, - conflict_note: None, - } - } -} - -/// The result of successfully applying a command. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandResult { - /// The command that was applied. - pub command_id: Uuid, - /// The command's new status. - pub status: CommandStatus, - /// Any entity UUID produced by the operation (e.g., the new node's UUID). - pub produced_id: Option, - /// Whether this was a no-op due to idempotency. - pub was_idempotent: bool, -} - -// ── Payload schema helpers ──────────────────────────────────────────────────── - -/// Payload for CreateNode commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateNodePayload { - pub node_id: Uuid, - pub node_type: String, - pub embedding: Vec, - pub content: Vec, - pub tier: MemoryTier, - pub importance: f32, -} - -/// Payload for UpdateNode commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateNodePayload { - pub node_id: Uuid, - pub new_content: Option>, - pub new_importance: Option, - pub new_tier: Option, -} - -/// Payload for DeleteNode commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeleteNodePayload { - pub node_id: Uuid, - /// The full node is saved at command-creation time so rollback can restore it. - pub snapshot: Value, -} - -/// Payload for CreateEdge commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateEdgePayload { - pub edge_id: Uuid, - pub from_id: Uuid, - pub to_id: Uuid, - pub relation: String, - pub weight: f32, -} - -/// Payload for DeleteEdge commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeleteEdgePayload { - pub edge_id: Uuid, - pub from_id: Uuid, - pub to_id: Uuid, - /// Full edge snapshot for rollback. - pub snapshot: Value, -} - -/// Payload for UpdateSalience commands. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateSaliencePayload { - pub node_id: Uuid, - pub new_salience: f32, - pub old_salience: f32, -} diff --git a/engrams/engram-tx/src/engine.rs b/engrams/engram-tx/src/engine.rs deleted file mode 100644 index 3386d44..0000000 --- a/engrams/engram-tx/src/engine.rs +++ /dev/null @@ -1,682 +0,0 @@ -/// TransactionEngine — applies commands to EngramDb and manages rollback. -/// -/// The engine wraps an `EngramDb` and a `CommandLog`. All mutations go through -/// `apply()`. Rollbacks create new inverse commands. Rolling back a rollback -/// re-applies the original — full undo/redo with causal tracking. -use engram_core::types::{Edge, Node, NodeType}; -use engram_core::EngramDb; -use serde_json::Value; -use uuid::Uuid; - -use crate::command::{ - Command, CommandResult, CommandStatus, CommandType, CreateEdgePayload, CreateNodePayload, - DeleteEdgePayload, DeleteNodePayload, UpdateNodePayload, UpdateSaliencePayload, -}; -use crate::error::{TxError, TxResult}; -use crate::log::CommandLog; - -pub struct TransactionEngine { - db: std::sync::Arc>, - log: CommandLog, - /// Our peer ID for conflict resolution. - peer_id: Option, -} - -impl TransactionEngine { - /// Create a new engine backed by the given database and a sled store for the log. - pub fn new( - db: std::sync::Arc>, - log_db: sled::Db, - peer_id: Option, - ) -> Self { - Self { - db, - log: CommandLog::open(log_db), - peer_id, - } - } - - // ── Public API ──────────────────────────────────────────────────────────── - - /// Apply a command to the database. - /// - /// Idempotency: if a command with the same `idempotency_key` has already - /// been applied, this is a no-op and returns the original command's result. - pub fn apply(&mut self, mut cmd: Command) -> TxResult { - // Idempotency check - if let Some(existing_id) = self.log.check_idempotency(&cmd.idempotency_key)? { - return Ok(CommandResult { - command_id: existing_id, - status: CommandStatus::Applied, - produced_id: None, - was_idempotent: true, - }); - } - - // Execute the operation - let produced_id = self.execute(&cmd)?; - - cmd.status = CommandStatus::Applied; - self.log.write(&cmd)?; - - Ok(CommandResult { - command_id: cmd.id, - status: CommandStatus::Applied, - produced_id, - was_idempotent: false, - }) - } - - /// Roll back a previously applied command. - /// - /// Creates and applies a new `Rollback(target_id)` command whose payload - /// is the inverse of the target command. The target command's status is - /// updated to `RolledBack`. - /// - /// Returns the new rollback command (useful for tracking / further rollback). - pub fn rollback(&mut self, target_id: Uuid) -> TxResult { - let target = self.log.require(target_id)?; - - if target.status == CommandStatus::RolledBack { - return Err(TxError::InvalidStatus(format!( - "command {} is already rolled back", - target_id - ))); - } - if target.status == CommandStatus::Pending { - return Err(TxError::InvalidStatus( - "cannot roll back a pending command".into(), - )); - } - - // The rollback's payload is the original command's inverse_payload. - // The rollback's inverse_payload is the original command's payload. - // This enables rollback-of-rollback to re-apply the original. - let rollback_key = format!("rollback:{}", target_id); - let rollback_cmd = Command::new( - CommandType::Rollback(target_id), - target.inverse_payload.clone(), - target.payload.clone(), - rollback_key, - Some(target_id), - self.peer_id, - ); - - // Execute the inverse operation - self.execute_inverse(&target)?; - - // Mark the original command as rolled back - let mut updated_target = target; - updated_target.status = CommandStatus::RolledBack; - self.log.write(&updated_target)?; - - // Persist the rollback command as Applied - let mut rb = rollback_cmd; - rb.status = CommandStatus::Applied; - self.log.write(&rb)?; - - Ok(rb) - } - - /// Roll back a rollback — re-applying the original command. - /// - /// This is "undo the undo". The rollback_id must be a command of type - /// `Rollback(original_id)`. Rolling it back re-applies `original_id`. - pub fn rollback_rollback(&mut self, rollback_id: Uuid) -> TxResult { - let rb_cmd = self.log.require(rollback_id)?; - - // Verify this IS a rollback command - let original_id = match &rb_cmd.command_type { - CommandType::Rollback(orig) => *orig, - _ => { - return Err(TxError::Invalid(format!( - "command {} is not a Rollback command", - rollback_id - ))); - } - }; - - if rb_cmd.status == CommandStatus::RolledBack { - return Err(TxError::InvalidStatus( - "this rollback has itself already been rolled back".into(), - )); - } - - // The rollback_rollback's payload is rb_cmd.inverse_payload (= original payload) - // Its inverse is rb_cmd.payload (= original's inverse_payload) - let key = format!("rollback:{}", rollback_id); - let rr_cmd = Command::new( - CommandType::Rollback(rollback_id), - rb_cmd.inverse_payload.clone(), - rb_cmd.payload.clone(), - key, - Some(rollback_id), - self.peer_id, - ); - - // Re-apply the original command by executing the original's payload - let original = self.log.require(original_id)?; - self.execute(&original)?; - - // Mark original as Applied again - let mut orig = original; - orig.status = CommandStatus::Applied; - self.log.write(&orig)?; - - // Mark rollback as RolledBack - let mut rb = rb_cmd; - rb.status = CommandStatus::RolledBack; - self.log.write(&rb)?; - - // Persist the new re-apply command - let mut rr = rr_cmd; - rr.status = CommandStatus::Applied; - self.log.write(&rr)?; - - Ok(rr) - } - - /// All commands since a given Unix millisecond timestamp. - pub fn history(&self, since_ms: i64) -> TxResult> { - self.log.since(since_ms) - } - - /// The causal chain for a given command (root-first). - pub fn causal_chain(&self, command_id: Uuid) -> TxResult> { - self.log.causal_chain(command_id) - } - - /// Access the command log directly (for server routes). - pub fn log(&self) -> &CommandLog { - &self.log - } - - // ── Command execution ───────────────────────────────────────────────────── - - fn execute(&self, cmd: &Command) -> TxResult> { - match &cmd.command_type { - CommandType::CreateNode => self.exec_create_node(&cmd.payload), - CommandType::UpdateNode => { - self.exec_update_node(&cmd.payload)?; - Ok(None) - } - CommandType::DeleteNode => { - self.exec_delete_node(&cmd.payload)?; - Ok(None) - } - CommandType::CreateEdge => { - self.exec_create_edge(&cmd.payload)?; - Ok(None) - } - CommandType::DeleteEdge => { - self.exec_delete_edge(&cmd.payload)?; - Ok(None) - } - CommandType::UpdateSalience => { - self.exec_update_salience(&cmd.payload)?; - Ok(None) - } - CommandType::BulkImport => { - self.exec_bulk_import(&cmd.payload)?; - Ok(None) - } - CommandType::Rollback(_) => { - // Rollback commands are executed via execute_inverse on the target - Ok(None) - } - } - } - - fn execute_inverse(&self, cmd: &Command) -> TxResult<()> { - // The inverse is described by inverse_payload, which mirrors the command type - match &cmd.command_type { - CommandType::CreateNode => { - // Inverse of CreateNode is DeleteNode - let p: CreateNodePayload = serde_json::from_value(cmd.payload.clone())?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.delete_node(p.node_id)?; - } - CommandType::DeleteNode => { - // Inverse of DeleteNode is re-creating the node from snapshot - let p: DeleteNodePayload = serde_json::from_value(cmd.payload.clone())?; - let node: Node = serde_json::from_value(p.snapshot)?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.put_node(node)?; - } - CommandType::UpdateNode => { - // Inverse is applying the inverse_payload (prior state) - self.exec_update_node(&cmd.inverse_payload)?; - } - CommandType::CreateEdge => { - // Inverse of CreateEdge is DeleteEdge - let p: CreateEdgePayload = serde_json::from_value(cmd.payload.clone())?; - self.delete_edge_by_pair(p.from_id, p.to_id)?; - } - CommandType::DeleteEdge => { - // Inverse of DeleteEdge is re-creating the edge from snapshot - let p: DeleteEdgePayload = serde_json::from_value(cmd.payload.clone())?; - let edge: Edge = serde_json::from_value(p.snapshot)?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.put_edge(edge)?; - } - CommandType::UpdateSalience => { - // Restore old salience - let p: UpdateSaliencePayload = serde_json::from_value(cmd.payload.clone())?; - let restore = UpdateSaliencePayload { - node_id: p.node_id, - new_salience: p.old_salience, - old_salience: p.new_salience, - }; - self.exec_update_salience(&serde_json::to_value(restore)?)?; - } - CommandType::BulkImport | CommandType::Rollback(_) => { - // BulkImport rollback would need to individually undo each item. - // For now, we store the inverse_payload as instructions and log the gap. - // TODO: implement fine-grained BulkImport rollback - } - } - Ok(()) - } - - // ── Operation implementations ───────────────────────────────────────────── - - fn exec_create_node(&self, payload: &Value) -> TxResult> { - let p: CreateNodePayload = serde_json::from_value(payload.clone())?; - let node_type = parse_node_type(&p.node_type)?; - let tier = p.tier; - let node = Node::new(node_type, p.embedding, p.content, tier, p.importance) - .with_id(p.node_id); - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.put_node(node)?; - Ok(Some(p.node_id)) - } - - fn exec_update_node(&self, payload: &Value) -> TxResult<()> { - let p: UpdateNodePayload = serde_json::from_value(payload.clone())?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - let mut node = db - .get_node(p.node_id)? - .ok_or(TxError::NotFound(p.node_id))?; - if let Some(content) = p.new_content { - node.content = content; - } - if let Some(importance) = p.new_importance { - node.importance = importance.clamp(0.0, 1.0); - } - if let Some(tier) = p.new_tier { - node.tier = tier; - } - db.put_node(node)?; - Ok(()) - } - - fn exec_delete_node(&self, payload: &Value) -> TxResult<()> { - let p: DeleteNodePayload = serde_json::from_value(payload.clone())?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.delete_node(p.node_id)?; - Ok(()) - } - - fn exec_create_edge(&self, payload: &Value) -> TxResult<()> { - let p: CreateEdgePayload = serde_json::from_value(payload.clone())?; - let edge = Edge::new(p.from_id, p.to_id, p.relation, p.weight); - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - db.put_edge(edge)?; - Ok(()) - } - - fn exec_delete_edge(&self, payload: &Value) -> TxResult<()> { - let p: DeleteEdgePayload = serde_json::from_value(payload.clone())?; - self.delete_edge_by_pair(p.from_id, p.to_id)?; - Ok(()) - } - - fn exec_update_salience(&self, payload: &Value) -> TxResult<()> { - let p: UpdateSaliencePayload = serde_json::from_value(payload.clone())?; - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - let mut node = db - .get_node(p.node_id)? - .ok_or(TxError::NotFound(p.node_id))?; - node.salience = p.new_salience; - db.put_node(node)?; - Ok(()) - } - - fn exec_bulk_import(&self, payload: &Value) -> TxResult<()> { - let nodes_val = payload.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let edges_val = payload.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - for nv in nodes_val { - let node: Node = serde_json::from_value(nv)?; - db.put_node(node)?; - } - for ev in edges_val { - let edge: Edge = serde_json::from_value(ev)?; - db.put_edge(edge)?; - } - Ok(()) - } - - fn delete_edge_by_pair(&self, from_id: Uuid, to_id: Uuid) -> TxResult<()> { - // sled stores edges at "edges:from:{from}:{to}" — we remove both directions - let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?; - // We can't directly call into storage here without re-exposing internals, - // so we use the public scan-and-check approach via get_edges_from - let edges = db.get_edges_from(from_id)?; - for edge in edges { - if edge.to_id == to_id { - // Re-insert a tombstone isn't directly supported — we use the - // internal sled key to delete. Since we don't have direct sled - // access through EngramDb's public API, we rely on the fact that - // put_edge with weight=0 effectively nullifies it, but for proper - // deletion we need access to the underlying store. - // - // We work around this by storing a zero-weight edge with weight=-1 - // as a sentinel, or by exposing delete_edge. Since delete_node is - // already exposed, we add a helper. For now, we store the edge with - // weight 0 (marking it inactive) until a delete_edge API is added. - // - // TODO: add db.delete_edge() to engram-core public API. - // For now: overwrite with weight 0 to effectively disable it. - let mut tombstone = edge; - tombstone.weight = 0.0; - db.put_edge(tombstone)?; - break; - } - } - Ok(()) - } -} - -// ── Command builder helpers ─────────────────────────────────────────────────── - -/// Build a CreateNode command with eagerly-computed inverse. -pub fn build_create_node_cmd( - node: &Node, - idempotency_key: impl Into, - peer_id: Option, -) -> Command { - let payload = serde_json::to_value(CreateNodePayload { - node_id: node.id, - node_type: node_type_to_str(&node.node_type).to_string(), - embedding: node.embedding.clone(), - content: node.content.clone(), - tier: node.tier.clone(), - importance: node.importance, - }) - .unwrap_or(Value::Null); - - // Inverse: delete the node we're about to create - let inverse = serde_json::to_value(DeleteNodePayload { - node_id: node.id, - snapshot: serde_json::to_value(node).unwrap_or(Value::Null), - }) - .unwrap_or(Value::Null); - - Command::new( - CommandType::CreateNode, - payload, - inverse, - idempotency_key, - None, - peer_id, - ) -} - -/// Build a DeleteNode command with eagerly-computed inverse (snapshot). -pub fn build_delete_node_cmd( - node: &Node, - idempotency_key: impl Into, - peer_id: Option, -) -> Command { - let snapshot = serde_json::to_value(node).unwrap_or(Value::Null); - let payload = serde_json::to_value(DeleteNodePayload { - node_id: node.id, - snapshot: snapshot.clone(), - }) - .unwrap_or(Value::Null); - - // Inverse: re-create the node from snapshot - let inverse = serde_json::to_value(CreateNodePayload { - node_id: node.id, - node_type: node_type_to_str(&node.node_type).to_string(), - embedding: node.embedding.clone(), - content: node.content.clone(), - tier: node.tier.clone(), - importance: node.importance, - }) - .unwrap_or(Value::Null); - - Command::new( - CommandType::DeleteNode, - payload, - inverse, - idempotency_key, - None, - peer_id, - ) -} - -/// Build a CreateEdge command. -pub fn build_create_edge_cmd( - edge: &Edge, - idempotency_key: impl Into, - peer_id: Option, -) -> Command { - let payload = serde_json::to_value(CreateEdgePayload { - edge_id: edge.id, - from_id: edge.from_id, - to_id: edge.to_id, - relation: edge.relation.clone(), - weight: edge.weight, - }) - .unwrap_or(Value::Null); - - // Inverse: delete the edge - let snapshot = serde_json::to_value(edge).unwrap_or(Value::Null); - let inverse = serde_json::to_value(DeleteEdgePayload { - edge_id: edge.id, - from_id: edge.from_id, - to_id: edge.to_id, - snapshot, - }) - .unwrap_or(Value::Null); - - Command::new( - CommandType::CreateEdge, - payload, - inverse, - idempotency_key, - None, - peer_id, - ) -} - -// ── Type string helpers ─────────────────────────────────────────────────────── - -fn parse_node_type(s: &str) -> TxResult { - match s { - "Memory" => Ok(NodeType::Memory), - "Concept" => Ok(NodeType::Concept), - "Event" => Ok(NodeType::Event), - "Entity" => Ok(NodeType::Entity), - "Process" => Ok(NodeType::Process), - "InternalState" => Ok(NodeType::InternalState), - other => Ok(NodeType::Custom(other.to_string())), - } -} - -fn node_type_to_str(t: &NodeType) -> String { - match t { - NodeType::Memory => "Memory".to_string(), - NodeType::Concept => "Concept".to_string(), - NodeType::Event => "Event".to_string(), - NodeType::Entity => "Entity".to_string(), - NodeType::Process => "Process".to_string(), - NodeType::InternalState => "InternalState".to_string(), - NodeType::Custom(s) => s.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use engram_core::types::{MemoryTier, Node, NodeType}; - use tempfile::TempDir; - - fn make_engine() -> (TransactionEngine, TempDir, TempDir) { - let db_dir = TempDir::new().unwrap(); - let log_dir = TempDir::new().unwrap(); - let db = engram_core::EngramDb::open(db_dir.path()).unwrap(); - let db = std::sync::Arc::new(std::sync::Mutex::new(db)); - let log_db = sled::open(log_dir.path()).unwrap(); - let engine = TransactionEngine::new(db, log_db, None); - (engine, db_dir, log_dir) - } - - fn make_node() -> Node { - Node::new( - NodeType::Memory, - vec![1.0, 0.0], - b"test content".to_vec(), - MemoryTier::Semantic, - 0.8, - ) - } - - #[test] - fn test_apply_create_node() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let cmd = build_create_node_cmd(&node, "create-test-1", None); - let result = engine.apply(cmd).unwrap(); - assert_eq!(result.status, CommandStatus::Applied); - assert!(!result.was_idempotent); - - // Node should now exist - let db = engine.db.lock().unwrap(); - let found = db.get_node(node.id).unwrap(); - assert!(found.is_some()); - assert_eq!(found.unwrap().content, b"test content"); - } - - #[test] - fn test_idempotency() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let cmd1 = build_create_node_cmd(&node, "idem-key-42", None); - let cmd2 = build_create_node_cmd(&node, "idem-key-42", None); - - let r1 = engine.apply(cmd1).unwrap(); - let r2 = engine.apply(cmd2).unwrap(); - - assert!(!r1.was_idempotent); - assert!(r2.was_idempotent); - } - - #[test] - fn test_rollback_delete_node() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let node_id = node.id; - - // First apply create - let create_cmd = build_create_node_cmd(&node, "create-rb-1", None); - let result = engine.apply(create_cmd).unwrap(); - - // Roll back the creation — should delete the node - let rb = engine.rollback(result.command_id).unwrap(); - assert_eq!(rb.status, CommandStatus::Applied); - - let db = engine.db.lock().unwrap(); - let found = db.get_node(node_id).unwrap(); - assert!(found.is_none()); - } - - #[test] - fn test_rollback_of_rollback() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let node_id = node.id; - - // Create the node - let create_cmd = build_create_node_cmd(&node, "create-rorb-1", None); - let create_result = engine.apply(create_cmd).unwrap(); - - // Roll back (delete) - let rb = engine.rollback(create_result.command_id).unwrap(); - - // Verify it's gone - { - let db = engine.db.lock().unwrap(); - assert!(db.get_node(node_id).unwrap().is_none()); - } - - // Roll back the rollback (re-create) - let _rr = engine.rollback_rollback(rb.id).unwrap(); - - // Node should be back - let db = engine.db.lock().unwrap(); - let found = db.get_node(node_id).unwrap(); - assert!(found.is_some()); - assert_eq!(found.unwrap().content, b"test content"); - } - - #[test] - fn test_history() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let cmd = build_create_node_cmd(&node, "hist-1", None); - engine.apply(cmd).unwrap(); - - let history = engine.history(0).unwrap(); - assert!(!history.is_empty()); - assert!(history.iter().any(|c| matches!(c.command_type, CommandType::CreateNode))); - } - - #[test] - fn test_causal_chain() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - let node = make_node(); - let cmd = build_create_node_cmd(&node, "causal-1", None); - let result = engine.apply(cmd).unwrap(); - - // Roll back (creates a child command with causal_parent = create_cmd.id) - let rb = engine.rollback(result.command_id).unwrap(); - - let chain = engine.causal_chain(rb.id).unwrap(); - // Chain should be [create_cmd, rollback_cmd] - assert_eq!(chain.len(), 2); - assert!(matches!(chain[0].command_type, CommandType::CreateNode)); - assert!(matches!(chain[1].command_type, CommandType::Rollback(_))); - } - - #[test] - fn test_create_edge_command() { - let (mut engine, _db_dir, _log_dir) = make_engine(); - - // Create two nodes first - let n1 = make_node(); - let n2 = Node::new( - NodeType::Concept, - vec![0.0, 1.0], - b"concept".to_vec(), - MemoryTier::Semantic, - 0.5, - ); - engine.apply(build_create_node_cmd(&n1, "edge-n1", None)).unwrap(); - engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap(); - - // Create edge - let edge = Edge::new(n1.id, n2.id, "references", 0.7); - let edge_cmd = build_create_edge_cmd(&edge, "edge-create-1", None); - let result = engine.apply(edge_cmd).unwrap(); - assert_eq!(result.status, CommandStatus::Applied); - - // Verify edge exists - let db = engine.db.lock().unwrap(); - let edges = db.get_edges_from(n1.id).unwrap(); - assert!(!edges.is_empty()); - } -} diff --git a/engrams/engram-tx/src/error.rs b/engrams/engram-tx/src/error.rs deleted file mode 100644 index 2f20c55..0000000 --- a/engrams/engram-tx/src/error.rs +++ /dev/null @@ -1,31 +0,0 @@ -use thiserror::Error; -use uuid::Uuid; - -#[derive(Debug, Error)] -pub enum TxError { - #[error("Command not found: {0}")] - NotFound(Uuid), - - #[error("Command already applied (idempotency key: {0})")] - AlreadyApplied(String), - - #[error("Cannot roll back a command in status {0:?}")] - InvalidStatus(String), - - #[error("Conflict: {0}")] - Conflict(String), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Storage error: {0}")] - Storage(#[from] sled::Error), - - #[error("Engram error: {0}")] - Engram(#[from] engram_core::EngramError), - - #[error("Invalid command: {0}")] - Invalid(String), -} - -pub type TxResult = Result; diff --git a/engrams/engram-tx/src/lib.rs b/engrams/engram-tx/src/lib.rs deleted file mode 100644 index 12cf541..0000000 --- a/engrams/engram-tx/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -/// Engram Transaction Engine — Command pattern with rollback-of-rollback. -/// -/// # The Central Insight -/// -/// Every mutation to Engram is a Command — a first-class object with: -/// - The operation and its inverse (computed eagerly at command time) -/// - An idempotency key (same key = same command, applied once) -/// - A causal parent (which command caused this one) -/// - A timestamp and originating peer ID -/// -/// Commands form a DAG of causality. You can roll back any command. You can -/// roll back a rollback (undo an undo — re-applying the original). The -/// command log is append-only: history is never rewritten. -/// -/// # Rollback-of-Rollback -/// -/// When you roll back command X, a new `Rollback(X)` command is created and -/// applied. Its `inverse_payload` is X's original `payload`. If you then roll -/// back the rollback, a new `Rollback(rollback_id)` is created, whose effect -/// is to re-apply X. This is a full undo/redo system with causal lineage. -pub mod command; -pub mod engine; -pub mod error; -pub mod log; - -pub use command::{Command, CommandResult, CommandStatus, CommandType}; -pub use engine::TransactionEngine; -pub use error::TxError; -pub use log::CommandLog; diff --git a/engrams/engram-tx/src/log.rs b/engrams/engram-tx/src/log.rs deleted file mode 100644 index 06e244f..0000000 --- a/engrams/engram-tx/src/log.rs +++ /dev/null @@ -1,151 +0,0 @@ -/// CommandLog — append-only log of all commands, stored in sled. -/// -/// Key schema: -/// cmd:{uuid} → JSON-encoded Command (JSON used because Command contains serde_json::Value) -/// idem:{key} → uuid bytes (idempotency index) -/// cmd_ts:{ms}:{uuid} → uuid bytes (time-ordered scan index) -use sled::Db; -use uuid::Uuid; - -use crate::command::Command; -use crate::error::{TxError, TxResult}; - -pub struct CommandLog { - db: Db, -} - -impl CommandLog { - pub fn open(db: Db) -> Self { - Self { db } - } - - // ── Write ───────────────────────────────────────────────────────────────── - - /// Append a command to the log. Overwrites if the UUID already exists - /// (used for status updates after application). - pub fn write(&self, cmd: &Command) -> TxResult<()> { - let key = cmd_key(cmd.id); - // Use JSON (not bincode) because Command contains serde_json::Value, - // which bincode cannot deserialize (DeserializeAnyNotSupported). - let val = serde_json::to_vec(cmd)?; - self.db.insert(key, val)?; - - // Idempotency index: idem:{key} → uuid - let idem_key = idem_key(&cmd.idempotency_key); - self.db.insert(idem_key, cmd.id.as_bytes().to_vec())?; - - // Time index: cmd_ts:{ms:016x}:{uuid} → uuid - let ts_key = ts_key(cmd.timestamp_ms, cmd.id); - self.db.insert(ts_key, cmd.id.as_bytes().to_vec())?; - - Ok(()) - } - - // ── Read ────────────────────────────────────────────────────────────────── - - pub fn get(&self, id: Uuid) -> TxResult> { - match self.db.get(cmd_key(id))? { - Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), - None => Ok(None), - } - } - - pub fn require(&self, id: Uuid) -> TxResult { - self.get(id)?.ok_or(TxError::NotFound(id)) - } - - /// Check whether an idempotency key has already been applied. - /// Returns the command UUID if it exists. - pub fn check_idempotency(&self, key: &str) -> TxResult> { - match self.db.get(idem_key(key))? { - Some(bytes) => { - let arr: [u8; 16] = bytes[..16] - .try_into() - .map_err(|_| TxError::Invalid("bad uuid bytes in idem index".into()))?; - Ok(Some(Uuid::from_bytes(arr))) - } - None => Ok(None), - } - } - - /// Load all commands created at or after `since_ms`, ordered by timestamp. - pub fn since(&self, since_ms: i64) -> TxResult> { - let prefix = format!("cmd_ts:{:016x}:", since_ms); - let mut cmds = Vec::new(); - for result in self.db.range(prefix.as_bytes()..) { - let (k, _v) = result?; - // Check the key starts with "cmd_ts:" - if !k.starts_with(b"cmd_ts:") { - break; - } - // Extract uuid from key: cmd_ts:{ms}:{uuid} - let key_str = std::str::from_utf8(&k) - .map_err(|e| TxError::Invalid(e.to_string()))?; - let parts: Vec<&str> = key_str.splitn(3, ':').collect(); - if parts.len() < 3 { - continue; - } - // parts[1] = ms (hex), parts[2] = uuid - let ts_hex = parts[1]; - let ts = i64::from_str_radix(ts_hex, 16) - .map_err(|e| TxError::Invalid(e.to_string()))?; - if ts < since_ms { - continue; - } - let id: Uuid = parts[2] - .parse() - .map_err(|e: uuid::Error| TxError::Invalid(e.to_string()))?; - if let Some(cmd) = self.get(id)? { - cmds.push(cmd); - } - } - Ok(cmds) - } - - /// Load all commands in the store. - pub fn all(&self) -> TxResult> { - self.since(0) - } - - /// Collect the causal chain leading to a given command (inclusive). - /// Walks `causal_parent` links back to the root. - pub fn causal_chain(&self, id: Uuid) -> TxResult> { - let mut chain = Vec::new(); - let mut current_id = Some(id); - let mut visited = std::collections::HashSet::new(); - - while let Some(cid) = current_id { - if visited.contains(&cid) { - break; // cycle guard - } - visited.insert(cid); - - match self.get(cid)? { - Some(cmd) => { - current_id = cmd.causal_parent; - chain.push(cmd); - } - None => break, - } - } - - // Return root-first (reverse of walk order) - chain.reverse(); - Ok(chain) - } -} - -// ── Key constructors ────────────────────────────────────────────────────────── - -fn cmd_key(id: Uuid) -> Vec { - format!("cmd:{}", id).into_bytes() -} - -fn idem_key(key: &str) -> Vec { - format!("idem:{}", key).into_bytes() -} - -fn ts_key(ts: i64, id: Uuid) -> Vec { - // Zero-padded hex timestamp for lexicographic ordering - format!("cmd_ts:{:016x}:{}", ts, id).into_bytes() -} diff --git a/examples/basic.rs b/examples/basic.rs deleted file mode 100644 index edda60f..0000000 --- a/examples/basic.rs +++ /dev/null @@ -1,265 +0,0 @@ -/// Basic engram demonstration. -/// -/// This example builds a small memory graph, runs spreading activation, -/// performs a vector search, shows salience decay, and demonstrates -/// the consolidation engine promoting Episodic nodes to Semantic. -/// -/// The nodes represent a tiny knowledge graph about the spreading activation -/// model itself — somewhat recursive, intentionally. -use engram_core::{ - ActivatedNode, ConsolidationConfig, Edge, EngramDb, MemoryTier, Node, NodeType, - EDGE_ACTIVATES, EDGE_CAUSES, EDGE_EXEMPLIFIES, EDGE_REFERENCES, EDGE_TEMPORALLY_PRECEDES, -}; -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, EDGE_CAUSES, 0.9))?; - // LTP is Referenced by Hebbian learning - db.put_edge(Edge::new(id1, id2, EDGE_REFERENCES, 0.85))?; - // Spreading activation Activates associative memory - db.put_edge(Edge::new(id0, id3, EDGE_ACTIVATES, 0.88))?; - // Hebbian learning Exemplifies associative memory - db.put_edge(Edge::new(id2, id3, EDGE_EXEMPLIFIES, 0.80))?; - // Salience decay TemporallyPrecedes naive forgetting - db.put_edge(Edge::new(id4, id5, EDGE_TEMPORALLY_PRECEDES, 0.65))?; - // LTP TemporallyPrecedes memory consolidation - db.put_edge(Edge::new(id1, id5, EDGE_TEMPORALLY_PRECEDES, 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!(); - - // ── 8. Consolidation ────────────────────────────────────────────────────── - // - // Touch node2 (Hebbian learning — Episodic) several times to simulate it - // being frequently recalled, then run consolidation to promote it. - - println!("=== Memory Consolidation ==="); - - // Simulate repeated activation: touch node2 six times so it crosses the - // default threshold of 5 activations. - for _ in 0..6 { - db.touch(id2)?; - } - - // Confirm the node's activation count has increased. - if let Some(n) = db.get_node(id2)? { - println!(" node2 (Episodic) activation_count before consolidation: {}", n.activation_count); - println!(" node2 tier before consolidation: {:?}", n.tier); - } - - let config = ConsolidationConfig { - episodic_to_semantic_threshold: 5, - salience_floor: 0.0, // no salience floor — accept any salient node - max_promotions_per_run: 10, - decay_factor: 0.98, - }; - - let report = db.consolidate(&config)?; - println!(" Promoted {} Episodic → Semantic", report.promoted); - println!(" Decayed {} nodes", report.decayed); - - // Confirm node2 has been promoted. - if let Some(n) = db.get_node(id2)? { - println!(" node2 tier after consolidation: {:?}", n.tier); - } - - println!(); - println!("Done. Engram v0.1.1."); - Ok(()) -} diff --git a/examples/migrate.rs b/examples/migrate.rs deleted file mode 100644 index d793de1..0000000 --- a/examples/migrate.rs +++ /dev/null @@ -1,96 +0,0 @@ -/// Migration example — shows the migrate_from_neuron API. -/// -/// This example creates a tiny in-memory SQLite database that mimics the -/// Neuron schema, then migrates it into an Engram sled store and prints -/// the resulting node count. -/// -/// In production: -/// use engram_core::migration::{migrate_from_neuron, MigrationConfig}; -/// let config = MigrationConfig::new( -/// PathBuf::from(shellexpand::tilde("~/.neuron/neuron.db").as_ref()), -/// PathBuf::from(shellexpand::tilde("~/.engram/neuron").as_ref()), -/// ); -/// let report = migrate_from_neuron(&config)?; -#[cfg(feature = "migration")] -fn main() -> Result<(), Box> { - use engram_core::migration::{migrate_from_neuron, MigrationConfig}; - use rusqlite::Connection; - use std::path::PathBuf; - - // 1. Create a temp Neuron-like SQLite database. - let tmp = tempfile::tempdir()?; - let sqlite_path = tmp.path().join("neuron.db"); - let engram_path = tmp.path().join("engram"); - - let conn = Connection::open(&sqlite_path)?; - conn.execute_batch( - "CREATE TABLE memory_nodes ( - id TEXT PRIMARY KEY, content TEXT NOT NULL, - importance TEXT NOT NULL DEFAULT 'normal', - superseded_by TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL - ); - CREATE TABLE knowledge_entries ( - id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', tier TEXT NOT NULL DEFAULT 'note', - tags TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL - ); - CREATE TABLE graph_edges ( - from_id TEXT NOT NULL, from_type TEXT NOT NULL, - to_id TEXT NOT NULL, to_type TEXT NOT NULL, - edge_type TEXT NOT NULL, weight REAL NOT NULL DEFAULT 1.0, - PRIMARY KEY (from_id, to_id, edge_type) - );", - )?; - - // Insert sample data. - for i in 0..5 { - conn.execute( - "INSERT INTO memory_nodes (id, content, importance, created_at, updated_at) - VALUES (?1, ?2, 'normal', 1000, 1000)", - rusqlite::params![format!("mem-{i}"), format!("Memory node {i}")], - )?; - } - for i in 0..3 { - conn.execute( - "INSERT INTO knowledge_entries (id, title, content, created_at, updated_at) - VALUES (?1, ?2, ?3, 2000, 2000)", - rusqlite::params![ - format!("kn-{i}"), - format!("Concept {i}"), - format!("Body of knowledge entry {i}"), - ], - )?; - } - drop(conn); - - // 2. Run the migration. - println!("Running migration..."); - let config = MigrationConfig { - sqlite_path, - engram_path, - embedding_dim: 64, - }; - - let report = migrate_from_neuron(&config)?; - - println!("Migration complete."); - println!(" Memories migrated: {}", report.memories_migrated); - println!(" Knowledge migrated: {}", report.knowledge_migrated); - println!(" Edges created: {}", report.edges_created); - if !report.errors.is_empty() { - println!(" Errors: {:?}", report.errors); - } - - // 3. Open the result and check counts. - let db = engram_core::EngramDb::open(&config.engram_path)?; - println!(); - println!("Engram node count: {}", db.node_count()?); - - Ok(()) -} - -#[cfg(not(feature = "migration"))] -fn main() { - eprintln!("This example requires the 'migration' feature."); - eprintln!("Run with: cargo run --example migrate --features migration"); -} diff --git a/receptors/go/README.md b/receptors/go/README.md deleted file mode 100644 index f07dd04..0000000 --- a/receptors/go/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# 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/receptors/go/engram.go b/receptors/go/engram.go deleted file mode 100644 index 94d1fb8..0000000 --- a/receptors/go/engram.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package engram provides Go bindings for the Engram memory substrate via CGo. -// -// Before using, build the shared library: -// -// cargo build --package engram-ffi --release -// -// Then either set LD_LIBRARY_PATH / DYLD_LIBRARY_PATH to the directory -// containing libengram_ffi.so/.dylib, or copy the library to a standard path. -// -// The LDFLAGS below assume you run `go build` from this directory and the -// Rust workspace is two levels up (../../target/release). -package engram - -/* -#cgo LDFLAGS: -L../../target/release -lengram_ffi -#include "engram.h" -#include -*/ -import "C" -import ( - "encoding/json" - "errors" - "fmt" - "unsafe" -) - -// ── Types ───────────────────────────────────────────────────────────────────── - -// DB is a handle to an open Engram database. -type DB struct { - ptr *C.EngramHandle -} - -// Node mirrors the Rust Node struct. -type Node struct { - ID string `json:"id"` - Content string `json:"content"` - NodeType string `json:"node_type"` - Tier string `json:"tier"` - Salience float32 `json:"salience"` - Importance float32 `json:"importance"` - ActivationCount uint64 `json:"activation_count"` - Embedding []float32 `json:"embedding"` -} - -// NodeInput is used when creating a new node. -type NodeInput struct { - Content string `json:"content"` - NodeType string `json:"node_type,omitempty"` - Tier string `json:"tier,omitempty"` - Importance float32 `json:"importance,omitempty"` - Embedding []float32 `json:"embedding"` -} - -// ActivatedNode is a node returned from spreading activation. -type ActivatedNode struct { - Node Node `json:"node"` - ActivationStrength float32 `json:"activation_strength"` - Hops uint8 `json:"hops"` -} - -// ActivateRequest is the JSON payload sent to engram_activate. -type activateRequest struct { - Seeds []string `json:"seeds"` - QueryEmbedding []float32 `json:"query_embedding"` - MaxDepth uint8 `json:"max_depth"` - Limit int `json:"limit"` -} - -// ── Lifecycle ───────────────────────────────────────────────────────────────── - -// Open opens or creates an Engram database at the given path. -func Open(path string) (*DB, error) { - cpath := C.CString(path) - defer C.free(unsafe.Pointer(cpath)) - - ptr := C.engram_open(cpath) - if ptr == nil { - return nil, fmt.Errorf("engram_open failed for path %q", path) - } - return &DB{ptr: ptr}, nil -} - -// Close closes the database and frees the native handle. -func (db *DB) Close() { - if db.ptr != nil { - C.engram_close(db.ptr) - db.ptr = nil - } -} - -// ── Statistics ──────────────────────────────────────────────────────────────── - -// NodeCount returns the total number of nodes. -func (db *DB) NodeCount() (uint64, error) { - n := C.engram_node_count(db.ptr) - if n < 0 { - return 0, errors.New("engram_node_count returned error") - } - return uint64(n), nil -} - -// EdgeCount returns the total number of edges. -func (db *DB) EdgeCount() (uint64, error) { - n := C.engram_edge_count(db.ptr) - if n < 0 { - return 0, errors.New("engram_edge_count returned error") - } - return uint64(n), nil -} - -// ── Node operations ─────────────────────────────────────────────────────────── - -// PutNode stores a node and returns its UUID. -func (db *DB) PutNode(node *NodeInput) (string, error) { - jsonBytes, err := json.Marshal(node) - if err != nil { - return "", fmt.Errorf("marshal node: %w", err) - } - - cjson := C.CString(string(jsonBytes)) - defer C.free(unsafe.Pointer(cjson)) - - result := C.engram_put_node(db.ptr, cjson) - if result == nil { - return "", errors.New("engram_put_node returned null") - } - defer C.engram_free_string(result) - - return C.GoString(result), nil -} - -// GetNode retrieves a node by UUID. Returns nil if not found. -func (db *DB) GetNode(id string) (*Node, error) { - cid := C.CString(id) - defer C.free(unsafe.Pointer(cid)) - - result := C.engram_get_node(db.ptr, cid) - if result == nil { - return nil, nil - } - defer C.engram_free_string(result) - - var node Node - if err := json.Unmarshal([]byte(C.GoString(result)), &node); err != nil { - return nil, fmt.Errorf("unmarshal node: %w", err) - } - return &node, nil -} - -// ── Spreading activation ────────────────────────────────────────────────────── - -// Activate runs spreading activation from seed node UUIDs. -func (db *DB) Activate( - seeds []string, - queryEmbedding []float32, - maxDepth uint8, - limit int, -) ([]*ActivatedNode, error) { - req := activateRequest{ - Seeds: seeds, - QueryEmbedding: queryEmbedding, - MaxDepth: maxDepth, - Limit: limit, - } - jsonBytes, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal activate request: %w", err) - } - - cjson := C.CString(string(jsonBytes)) - defer C.free(unsafe.Pointer(cjson)) - - result := C.engram_activate(db.ptr, cjson) - if result == nil { - return nil, errors.New("engram_activate returned null") - } - defer C.engram_free_string(result) - - var nodes []*ActivatedNode - if err := json.Unmarshal([]byte(C.GoString(result)), &nodes); err != nil { - return nil, fmt.Errorf("unmarshal activate result: %w", err) - } - return nodes, nil -} - -// ── Salience management ─────────────────────────────────────────────────────── - -// Decay applies multiplicative salience decay to all nodes. -// factor should be in (0.0, 1.0). Returns the number of nodes updated. -func (db *DB) Decay(factor float32) (uint64, error) { - n := C.engram_decay(db.ptr, C.float(factor)) - if n < 0 { - return 0, errors.New("engram_decay returned error") - } - return uint64(n), nil -} diff --git a/receptors/go/engram.h b/receptors/go/engram.h deleted file mode 100644 index f2721cd..0000000 --- a/receptors/go/engram.h +++ /dev/null @@ -1,70 +0,0 @@ -/** - * engram.h — C header for the engram FFI. - * - * Generated from crates/engram-ffi/src/lib.rs. - * To regenerate: cargo install cbindgen && cbindgen --crate engram-ffi -o bindings/go/engram.h - * - * Build the shared library: - * cargo build --package engram-ffi --release - * # macOS: target/release/libengram_ffi.dylib - * # Linux: target/release/libengram_ffi.so - */ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** Opaque handle to an open EngramDb. Obtained via engram_open. */ -typedef struct EngramHandle EngramHandle; - -/** Open or create an engram database at `path`. Returns null on error. */ -EngramHandle* engram_open(const char* path); - -/** Close and free a database handle. The pointer must not be used afterwards. */ -void engram_close(EngramHandle* handle); - -/** Return the number of nodes in the database, or -1 on error. */ -int64_t engram_node_count(const EngramHandle* handle); - -/** Return the number of edges in the database, or -1 on error. */ -int64_t engram_edge_count(const EngramHandle* handle); - -/** - * Apply multiplicative salience decay to all nodes. - * `factor` should be in (0.0, 1.0). Returns nodes updated, or -1 on error. - */ -int64_t engram_decay(EngramHandle* handle, float factor); - -/** - * Store a node from a JSON string. - * JSON: { "content": "...", "node_type": "Memory", "tier": "Episodic", - * "importance": 0.8, "embedding": [f32, ...] } - * Returns a heap-allocated UUID string on success, null on error. - * Free with engram_free_string. - */ -char* engram_put_node(EngramHandle* handle, const char* json); - -/** - * Retrieve a node by UUID. Returns a heap-allocated JSON string, or null. - * Free with engram_free_string. - */ -char* engram_get_node(const EngramHandle* handle, const char* id); - -/** - * Run spreading activation. - * `req_json`: { "seeds": ["uuid", ...], "query_embedding": [f32, ...], - * "max_depth": 3, "limit": 10 } - * Returns a heap-allocated JSON array, or null on error. - * Free with engram_free_string. - */ -char* engram_activate(const EngramHandle* handle, const char* req_json); - -/** Free a string returned by any engram FFI function. */ -void engram_free_string(char* s); - -#ifdef __cplusplus -} /* extern "C" */ -#endif diff --git a/receptors/go/engram_test.go b/receptors/go/engram_test.go deleted file mode 100644 index 9a6e210..0000000 --- a/receptors/go/engram_test.go +++ /dev/null @@ -1,130 +0,0 @@ -// Package engram basic test. -// -// NOTE: This test requires the FFI shared library to be compiled first: -// -// cargo build --package engram-ffi --release -// -// Then run: -// -// DYLD_LIBRARY_PATH=../../target/release go test ./... -// # or on Linux: -// LD_LIBRARY_PATH=../../target/release go test ./... -package engram - -import ( - "os" - "path/filepath" - "testing" -) - -// TestOpenClose verifies that a database can be opened and closed without errors. -func TestOpenClose(t *testing.T) { - dir := t.TempDir() - db, err := Open(filepath.Join(dir, "test-engram")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer db.Close() - - n, err := db.NodeCount() - if err != nil { - t.Fatalf("NodeCount: %v", err) - } - if n != 0 { - t.Errorf("expected 0 nodes, got %d", n) - } -} - -// TestPutGetNode verifies node storage and retrieval roundtrip. -func TestPutGetNode(t *testing.T) { - dir := t.TempDir() - db, err := Open(filepath.Join(dir, "test-engram")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer db.Close() - - input := &NodeInput{ - Content: "Spreading activation is the core retrieval mechanism", - NodeType: "Concept", - Tier: "Semantic", - Importance: 0.9, - Embedding: []float32{0.1, 0.2, 0.3, 0.4}, - } - - id, err := db.PutNode(input) - if err != nil { - t.Fatalf("PutNode: %v", err) - } - if id == "" { - t.Fatal("expected non-empty UUID") - } - - node, err := db.GetNode(id) - if err != nil { - t.Fatalf("GetNode: %v", err) - } - if node == nil { - t.Fatal("expected node, got nil") - } - if node.Content != input.Content { - t.Errorf("content mismatch: got %q, want %q", node.Content, input.Content) - } -} - -// TestNodeCount verifies node count increments. -func TestNodeCount(t *testing.T) { - dir := t.TempDir() - db, err := Open(filepath.Join(dir, "test-engram")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer db.Close() - - for i := 0; i < 3; i++ { - _, err := db.PutNode(&NodeInput{ - Content: "node content", - Embedding: []float32{float32(i), 0.0, 0.0}, - }) - if err != nil { - t.Fatalf("PutNode[%d]: %v", i, err) - } - } - - n, err := db.NodeCount() - if err != nil { - t.Fatalf("NodeCount: %v", err) - } - if n != 3 { - t.Errorf("expected 3 nodes, got %d", n) - } -} - -// TestDecay verifies salience decay runs without error. -func TestDecay(t *testing.T) { - dir := t.TempDir() - db, err := Open(filepath.Join(dir, "test-engram")) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer db.Close() - - _, err = db.PutNode(&NodeInput{ - Content: "test node", - Embedding: []float32{1.0, 0.0}, - }) - if err != nil { - t.Fatalf("PutNode: %v", err) - } - - updated, err := db.Decay(0.95) - if err != nil { - t.Fatalf("Decay: %v", err) - } - if updated == 0 { - t.Error("expected at least one node to be decayed") - } -} - -// ensure test file exists (compile guard) -var _ = os.DevNull diff --git a/receptors/go/go.mod b/receptors/go/go.mod deleted file mode 100644 index 66b3769..0000000 --- a/receptors/go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/neuron-technologies/engram/bindings/go - -go 1.21 diff --git a/receptors/kotlin/README.md b/receptors/kotlin/README.md deleted file mode 100644 index 2d44cac..0000000 --- a/receptors/kotlin/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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/receptors/kotlin/build.gradle.kts b/receptors/kotlin/build.gradle.kts deleted file mode 100644 index b932019..0000000 --- a/receptors/kotlin/build.gradle.kts +++ /dev/null @@ -1,33 +0,0 @@ -plugins { - kotlin("jvm") version "1.9.23" -} - -group = "ai.neuron" -version = "0.1.0" - -repositories { - mavenCentral() -} - -dependencies { - implementation(kotlin("stdlib")) - // org.json is available on Android; for JVM use the standalone artifact. - implementation("org.json:json:20240303") - - testImplementation(kotlin("test")) - testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") -} - -tasks.test { - useJUnitPlatform() - // Point to the compiled native library. - // Build first: cargo build --package engram-jni --release - systemProperty( - "java.library.path", - "${rootProject.projectDir}/../../target/release" - ) -} - -kotlin { - jvmToolchain(17) -} diff --git a/receptors/kotlin/settings.gradle.kts b/receptors/kotlin/settings.gradle.kts deleted file mode 100644 index e348727..0000000 --- a/receptors/kotlin/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "engram-kotlin" diff --git a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/ActivatedNode.kt b/receptors/kotlin/src/main/kotlin/ai/neuron/engram/ActivatedNode.kt deleted file mode 100644 index 7178347..0000000 --- a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/ActivatedNode.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ai.neuron.engram - -/** - * A node returned from spreading activation, annotated with how strongly it - * was activated and how many graph hops from the seed set it is. - */ -data class ActivatedNode( - val node: EngramNode, - /** Activation strength in [0, 1]. Higher = more relevant. */ - val activationStrength: Float, - /** Number of hops from the nearest seed node. */ - val hops: Int, -) diff --git a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramDb.kt b/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramDb.kt deleted file mode 100644 index d23823d..0000000 --- a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramDb.kt +++ /dev/null @@ -1,153 +0,0 @@ -package ai.neuron.engram - -import org.json.JSONArray -import org.json.JSONObject - -/** - * JNI wrapper around the native Engram database. - * - * The native `libengram_jni` shared library must be on the library path: - * - macOS: `libengram_jni.dylib` in a directory on `java.library.path` - * - Linux: `libengram_jni.so` - * - Android: bundled in the APK `jniLibs/` folder - * - * # Usage - * ```kotlin - * EngramDb("/data/engram").use { db -> - * val id = db.putNode(NodeInput("Hello, Engram", NodeType.Memory)) - * val node = db.getNode(id) - * println(node?.content) - * } - * ``` - */ -class EngramDb(path: String) : AutoCloseable { - // Native pointer — stored as Long, managed entirely by Rust. - private val handle: Long = open(path).also { - require(it != 0L) { "Failed to open engram database at: $path" } - } - - // ── Node operations ─────────────────────────────────────────────────────── - - /** Store a node and return its UUID. */ - fun putNode(node: NodeInput): String { - val json = JSONObject().apply { - put("content", node.content) - put("node_type", node.nodeType.name) - put("tier", node.tier.name) - put("importance", node.importance) - put("embedding", JSONArray(node.embedding.toTypedArray())) - }.toString() - return putNode(handle, json) ?: error("putNode returned null") - } - - /** Retrieve a node by UUID. Returns null if not found. */ - fun getNode(id: String): EngramNode? { - val json = getNode(handle, id) ?: return null - return nodeFromJson(JSONObject(json)) - } - - // ── Edge operations ─────────────────────────────────────────────────────── - - /** Store a directed edge between two nodes. */ - fun putEdge(edge: EngramEdge) { - // Edges are stored via the FFI activate pathway or direct node graph manipulation. - // For now, we use engram_put_node indirectly by encoding the edge as metadata. - // TODO: add engram_put_edge to the FFI surface in v0.1.2 - } - - // ── Vector search ───────────────────────────────────────────────────────── - - /** Find the `limit` most similar nodes by embedding vector. */ - fun searchEmbedding(embedding: FloatArray, limit: Int): List { - val seeds = emptyArray() - val json = activate(handle, "[]", embedding, 0, limit) ?: return emptyList() - return activatedNodesFromJson(json).map { it.node } - } - - // ── Spreading activation ────────────────────────────────────────────────── - - /** Run spreading activation from seed UUIDs. */ - fun activate( - seeds: Array, - queryEmbedding: FloatArray, - maxDepth: Int = 3, - limit: Int = 10, - ): List { - val seedsJson = JSONArray(seeds).toString() - val json = activate(handle, seedsJson, queryEmbedding, maxDepth, limit) ?: return emptyList() - return activatedNodesFromJson(json) - } - - // ── Salience management ─────────────────────────────────────────────────── - - /** Mark a node as recently accessed. */ - fun touch(id: String) = touch(handle, id) - - /** Apply multiplicative salience decay. Returns nodes updated. */ - fun decay(factor: Float): Int = decay(handle, factor) - - // ── Statistics ──────────────────────────────────────────────────────────── - - /** Total number of nodes. */ - fun nodeCount(): Long = nodeCount(handle) - - /** Total number of edges. */ - fun edgeCount(): Long = edgeCount(handle) - - // ── AutoCloseable ───────────────────────────────────────────────────────── - - override fun close() = close(handle) - - // ── Native declarations ─────────────────────────────────────────────────── - - private external fun open(path: String): Long - private external fun close(handle: Long) - private external fun putNode(handle: Long, nodeJson: String): String? - private external fun getNode(handle: Long, id: String): String? - private external fun activate( - handle: Long, - seedsJson: String, - queryEmbedding: FloatArray, - maxDepth: Int, - limit: Int, - ): String? - private external fun touch(handle: Long, id: String) - private external fun decay(handle: Long, factor: Float): Int - private external fun nodeCount(handle: Long): Long - private external fun edgeCount(handle: Long): Long - - companion object { - init { - System.loadLibrary("engram_jni") - } - } - - // ── JSON helpers ────────────────────────────────────────────────────────── - - private fun nodeFromJson(obj: JSONObject): EngramNode { - val embArray = obj.getJSONArray("embedding") - val embedding = FloatArray(embArray.length()) { embArray.getDouble(it).toFloat() } - return EngramNode( - id = obj.getString("id"), - content = obj.getString("content"), - nodeType = NodeType.valueOf(obj.getString("node_type")), - tier = MemoryTier.valueOf(obj.getString("tier")), - salience = obj.getDouble("salience").toFloat(), - importance = obj.getDouble("importance").toFloat(), - activationCount = obj.getLong("activation_count"), - embedding = embedding, - ) - } - - private fun activatedNodesFromJson(json: String): List { - val arr = JSONArray(json) - return (0 until arr.length()).map { i -> - val obj = arr.getJSONObject(i) - ActivatedNode( - node = nodeFromJson(obj.getJSONObject("node")), - activationStrength = obj.getDouble("activation_strength").toFloat(), - hops = obj.getInt("hops"), - ) - } - } -} diff --git a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramEdge.kt b/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramEdge.kt deleted file mode 100644 index 8db8fdc..0000000 --- a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramEdge.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ai.neuron.engram - -/** - * A directed, typed edge between two nodes. - * - * Mirrors the Rust `Edge` struct from `engram-core`. - */ -data class EngramEdge( - val id: String, - val fromId: String, - val toId: String, - val relation: RelationType, - val weight: Float, -) diff --git a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramNode.kt b/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramNode.kt deleted file mode 100644 index 8fa62c3..0000000 --- a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramNode.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ai.neuron.engram - -/** - * A node in the Engram memory graph. - * - * Mirrors the Rust `Node` struct from `engram-core`. - */ -data class EngramNode( - val id: String, - val content: String, - val nodeType: NodeType, - val tier: MemoryTier, - val salience: Float, - val importance: Float, - val activationCount: Long, - val embedding: FloatArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is EngramNode) return false - return id == other.id - } - - override fun hashCode(): Int = id.hashCode() -} - -/** - * Input type for creating a new node. - * Not all fields are required — `id`, `salience`, and `activationCount` - * are assigned by the database on insertion. - */ -data class NodeInput( - val content: String, - val nodeType: NodeType = NodeType.Memory, - val tier: MemoryTier = MemoryTier.Episodic, - val importance: Float = 0.5f, - val embedding: FloatArray = FloatArray(0), -) diff --git a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramTypes.kt b/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramTypes.kt deleted file mode 100644 index efcd789..0000000 --- a/receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramTypes.kt +++ /dev/null @@ -1,31 +0,0 @@ -package ai.neuron.engram - -/** The functional role of a node in the memory graph. */ -enum class NodeType { - Memory, - Concept, - Event, - Entity, - Process, - InternalState, -} - -/** Where in the memory hierarchy a node lives. */ -enum class MemoryTier { - Working, - Episodic, - Semantic, - Procedural, -} - -/** The typed relationship between two nodes. */ -enum class RelationType { - Supersedes, - Causes, - Contains, - References, - Contradicts, - Exemplifies, - Activates, - TemporallyPrecedes, -} diff --git a/receptors/typescript/Cargo.toml b/receptors/typescript/Cargo.toml deleted file mode 100644 index 20baade..0000000 --- a/receptors/typescript/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "engram-wasm" -version = "0.1.0" -edition = "2021" -description = "WASM/TypeScript bindings for engram-core via wasm-bindgen" -license = "MIT" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -engram-core = { path = "../../crates/engram-core", features = ["wasm"], default-features = false } -wasm-bindgen = "0.2" -serde-wasm-bindgen = "0.6" -serde = { version = "1", features = ["derive"] } -uuid = { version = "1", features = ["v4", "serde", "js"] } -getrandom = { version = "0.2", features = ["js"] } -console_error_panic_hook = { version = "0.1", optional = true } - -[features] -default = ["console_error_panic_hook"] - -[package.metadata.wasm-pack.profile.release] -wasm-opt = false diff --git a/receptors/typescript/README.md b/receptors/typescript/README.md deleted file mode 100644 index 29d9861..0000000 --- a/receptors/typescript/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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/receptors/typescript/package.json b/receptors/typescript/package.json deleted file mode 100644 index 7df50b8..0000000 --- a/receptors/typescript/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@neuron/engram", - "version": "0.1.0", - "description": "Engram memory substrate — TypeScript/WASM bindings", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist", - "pkg" - ], - "scripts": { - "build:wasm": "wasm-pack build . --target web --out-dir pkg", - "build:ts": "tsc", - "build": "npm run build:wasm && npm run build:ts", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0" - } -} diff --git a/receptors/typescript/src/index.ts b/receptors/typescript/src/index.ts deleted file mode 100644 index e729d02..0000000 --- a/receptors/typescript/src/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * TypeScript wrapper around the engram WASM module. - * - * Build the WASM first: - * wasm-pack build bindings/typescript --target web --out-dir pkg - * - * Then import: - * import { EngramDb } from "@neuron/engram"; - */ - -// @ts-ignore — generated by wasm-pack -import init, { WasmEngramDb } from "../pkg/engram_wasm.js"; - -import type { - NodeInput, - EngramNode, - ScoredNode, - ActivatedNode, - ConsolidationReport, -} from "./types"; - -export type { NodeInput, EngramNode, ScoredNode, ActivatedNode, ConsolidationReport }; - -let wasmInitialised = false; - -/** - * Initialise the WASM module. Must be called once before creating any EngramDb. - */ -export async function initEngram(): Promise { - if (!wasmInitialised) { - await init(); - wasmInitialised = true; - } -} - -/** - * TypeScript wrapper around `WasmEngramDb`. - * - * All state is in-memory (WASM has no filesystem access). The path argument - * is accepted for API symmetry but is ignored. - * - * ```ts - * await initEngram(); - * const db = new EngramDb(); - * - * const id = await db.putNode({ - * content: "Spreading activation drives recall", - * node_type: "Concept", - * tier: "Semantic", - * importance: 0.9, - * embedding: Array.from({ length: 384 }, () => Math.random()), - * }); - * - * const results = await db.searchEmbedding(queryEmbedding, 5); - * ``` - */ -export class EngramDb { - private db: WasmEngramDb; - - constructor(path = "/wasm-memory") { - this.db = new WasmEngramDb(path); - } - - /** Store a node and return its UUID. */ - putNode(node: NodeInput): string { - return this.db.put_node(node); - } - - /** Retrieve a node by UUID, or null if not found. */ - getNode(id: string): EngramNode | null { - return this.db.get_node(id); - } - - /** Find the `limit` most similar nodes by embedding vector. */ - searchEmbedding(embedding: Float32Array | number[], limit: number): ScoredNode[] { - const arr = embedding instanceof Float32Array ? embedding : new Float32Array(embedding); - return this.db.search_embedding(arr, limit); - } - - /** - * Run spreading activation from seed nodes. - * - * @param seeds Array of UUID strings (the "active context") - * @param queryEmbedding Semantic vector for the current query - * @param maxDepth Maximum BFS hops (typically 2–4) - * @param limit Number of results to return - */ - activate( - seeds: string[], - queryEmbedding: Float32Array | number[], - maxDepth = 3, - limit = 10 - ): ActivatedNode[] { - const arr = - queryEmbedding instanceof Float32Array - ? queryEmbedding - : new Float32Array(queryEmbedding); - return this.db.activate(seeds, arr, maxDepth, limit); - } - - /** Mark a node as recently accessed (increments activation count). */ - touch(id: string): void { - this.db.touch(id); - } - - /** Apply multiplicative salience decay. Returns the number of nodes updated. */ - decay(factor: number): number { - return this.db.decay(factor); - } - - /** Run a memory consolidation cycle. */ - consolidate(): ConsolidationReport { - return this.db.consolidate(); - } - - /** Total number of nodes stored. */ - nodeCount(): number { - return this.db.node_count(); - } - - /** Total number of edges stored. */ - edgeCount(): number { - return this.db.edge_count(); - } -} diff --git a/receptors/typescript/src/lib.rs b/receptors/typescript/src/lib.rs deleted file mode 100644 index e8768bb..0000000 --- a/receptors/typescript/src/lib.rs +++ /dev/null @@ -1,311 +0,0 @@ -/// WASM/TypeScript bindings for engram-core via wasm-bindgen. -/// -/// This crate compiles to a WebAssembly module that can be loaded by the -/// TypeScript wrapper in `src/index.ts`. All types are passed as JSON strings -/// across the WASM boundary to avoid bespoke serialisation code. -/// -/// # Storage -/// sled is not available in WASM (no filesystem). When compiled with the `wasm` -/// feature, engram-core switches to an in-memory HashMap backend. All state is -/// therefore lost on page reload — persistence requires sending nodes to a -/// server-side store and re-loading them on startup. -/// -/// # Build -/// ``` -/// wasm-pack build bindings/typescript --target web -/// ``` -use engram_core::{ - ActivatedNode, ConsolidationConfig, EngramDb, MemoryTier, Node, NodeType, ScoredNode, -}; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use uuid::Uuid; -use wasm_bindgen::prelude::*; - -// ── Panic hook ──────────────────────────────────────────────────────────────── - -#[wasm_bindgen(start)] -pub fn main() { - #[cfg(feature = "console_error_panic_hook")] - console_error_panic_hook::set_once(); -} - -// ── WasmEngramDb ───────────────────────────────────────────────────────────── - -/// The main entry point for WASM callers. -/// -/// TypeScript: -/// ```ts -/// const db = new WasmEngramDb("ignored-path"); -/// const id = db.putNode(JSON.stringify({ content: "...", node_type: "Memory", ... })); -/// ``` -#[wasm_bindgen] -pub struct WasmEngramDb { - inner: EngramDb, -} - -#[wasm_bindgen] -impl WasmEngramDb { - /// Create a new in-memory engram database. - /// - /// The `path` argument is accepted for API symmetry with the sled backend - /// but is ignored — all storage is in-memory. - #[wasm_bindgen(constructor)] - pub fn open(_path: &str) -> Result { - let db = EngramDb::open(Path::new("/wasm-memory")) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(WasmEngramDb { inner: db }) - } - - // ── Node operations ─────────────────────────────────────────────────────── - - /// Store a node. Accepts a JSON object with fields: - /// `{ content, node_type, tier, importance, embedding }` - /// Returns the assigned UUID string. - pub fn put_node(&self, node: JsValue) -> Result { - let n = js_value_to_node(node)?; - self.inner - .put_node(n) - .map(|id| id.to_string()) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - /// Retrieve a node by UUID. Returns a JSON object or null. - pub fn get_node(&self, id: &str) -> Result { - let uuid = id - .parse::() - .map_err(|e| JsValue::from_str(&e.to_string()))?; - match self - .inner - .get_node(uuid) - .map_err(|e| JsValue::from_str(&e.to_string()))? - { - Some(node) => node_to_js_value(&node), - None => Ok(JsValue::NULL), - } - } - - // ── Vector search ───────────────────────────────────────────────────────── - - /// Search for similar nodes by embedding vector. - /// - /// `embedding` is a JS Float32Array. Returns a JSON array of - /// `{ node, score }` objects. - pub fn search_embedding( - &self, - embedding: &[f32], - limit: usize, - ) -> Result { - let results = self - .inner - .search_embedding(embedding, limit) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - scored_nodes_to_js(&results) - } - - // ── Spreading activation ────────────────────────────────────────────────── - - /// Run spreading activation. - /// - /// `seeds` is a JS array of UUID strings. - /// `query_embedding` is a Float32Array. - /// Returns a JSON array of `{ node, activation_strength, hops }`. - pub fn activate( - &self, - seeds: JsValue, - query_embedding: &[f32], - max_depth: u8, - limit: usize, - ) -> Result { - let seed_strs: Vec = serde_wasm_bindgen::from_value(seeds) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - let seeds: Vec = seed_strs - .iter() - .filter_map(|s| s.parse::().ok()) - .collect(); - - let results = self - .inner - .activate(&seeds, query_embedding, max_depth, limit) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - activated_nodes_to_js(&results) - } - - // ── Salience management ─────────────────────────────────────────────────── - - /// Touch a node (increment activation count and update salience). - pub fn touch(&self, id: &str) -> Result<(), JsValue> { - let uuid = id - .parse::() - .map_err(|e| JsValue::from_str(&e.to_string()))?; - self.inner - .touch(uuid) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - /// Apply multiplicative salience decay. Returns the number of nodes updated. - pub fn decay(&self, factor: f32) -> Result { - self.inner - .decay(factor) - .map(|n| n as u32) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - // ── Consolidation ───────────────────────────────────────────────────────── - - /// Run a memory consolidation cycle. - /// Returns `{ promoted, decayed, pruned }`. - pub fn consolidate(&self) -> Result { - let config = ConsolidationConfig::default(); - let report = self - .inner - .consolidate(&config) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - #[derive(Serialize)] - struct Report { - promoted: usize, - decayed: usize, - pruned: usize, - } - serde_wasm_bindgen::to_value(&Report { - promoted: report.promoted, - decayed: report.decayed, - pruned: report.pruned, - }) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - // ── Statistics ──────────────────────────────────────────────────────────── - - /// Return the total number of nodes. - pub fn node_count(&self) -> Result { - self.inner - .node_count() - .map(|n| n as u32) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - /// Return the total number of edges. - pub fn edge_count(&self) -> Result { - self.inner - .edge_count() - .map(|n| n as u32) - .map_err(|e| JsValue::from_str(&e.to_string())) - } -} - -// ── Serialisation helpers ───────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct NodeInput { - content: String, - node_type: Option, - tier: Option, - importance: Option, - embedding: Option>, -} - -fn js_value_to_node(val: JsValue) -> Result { - let input: NodeInput = serde_wasm_bindgen::from_value(val) - .map_err(|e| JsValue::from_str(&format!("Invalid node: {e}")))?; - - let node_type = match input.node_type.as_deref().unwrap_or("Memory") { - "Concept" => NodeType::Concept, - "Event" => NodeType::Event, - "Entity" => NodeType::Entity, - "Process" => NodeType::Process, - "InternalState" => NodeType::InternalState, - _ => NodeType::Memory, - }; - let tier = match input.tier.as_deref().unwrap_or("Episodic") { - "Working" => MemoryTier::Working, - "Semantic" => MemoryTier::Semantic, - "Procedural" => MemoryTier::Procedural, - _ => MemoryTier::Episodic, - }; - let embedding = input.embedding.unwrap_or_default(); - let importance = input.importance.unwrap_or(0.5); - - Ok(Node::new(node_type, embedding, input.content.into_bytes(), tier, importance)) -} - -#[derive(Serialize)] -struct NodeOutput { - id: String, - content: String, - node_type: String, - tier: String, - salience: f32, - importance: f32, - activation_count: u64, - embedding: Vec, -} - -fn node_to_js_value(node: &Node) -> Result { - let out = NodeOutput { - id: node.id.to_string(), - content: String::from_utf8_lossy(&node.content).into_owned(), - node_type: format!("{:?}", node.node_type), - tier: format!("{:?}", node.tier), - salience: node.salience, - importance: node.importance, - activation_count: node.activation_count, - embedding: node.embedding.clone(), - }; - serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string())) -} - -#[derive(Serialize)] -struct ScoredNodeOutput { - node: NodeOutput, - score: f32, -} - -fn scored_nodes_to_js(nodes: &[ScoredNode]) -> Result { - let out: Vec = nodes - .iter() - .map(|s| ScoredNodeOutput { - node: NodeOutput { - id: s.node.id.to_string(), - content: String::from_utf8_lossy(&s.node.content).into_owned(), - node_type: format!("{:?}", s.node.node_type), - tier: format!("{:?}", s.node.tier), - salience: s.node.salience, - importance: s.node.importance, - activation_count: s.node.activation_count, - embedding: s.node.embedding.clone(), - }, - score: s.score, - }) - .collect(); - serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string())) -} - -#[derive(Serialize)] -struct ActivatedNodeOutput { - node: NodeOutput, - activation_strength: f32, - hops: u8, -} - -fn activated_nodes_to_js(nodes: &[ActivatedNode]) -> Result { - let out: Vec = nodes - .iter() - .map(|a| ActivatedNodeOutput { - node: NodeOutput { - id: a.node.id.to_string(), - content: String::from_utf8_lossy(&a.node.content).into_owned(), - node_type: format!("{:?}", a.node.node_type), - tier: format!("{:?}", a.node.tier), - salience: a.node.salience, - importance: a.node.importance, - activation_count: a.node.activation_count, - embedding: a.node.embedding.clone(), - }, - activation_strength: a.activation_strength, - hops: a.hops, - }) - .collect(); - serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string())) -} diff --git a/receptors/typescript/src/types.ts b/receptors/typescript/src/types.ts deleted file mode 100644 index be91ecb..0000000 --- a/receptors/typescript/src/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * TypeScript types mirroring the Rust structs in engram-core. - */ - -export type NodeType = - | "Memory" - | "Concept" - | "Event" - | "Entity" - | "Process" - | "InternalState"; - -export type MemoryTier = "Working" | "Episodic" | "Semantic" | "Procedural"; - -export interface EngramNode { - id: string; - content: string; - node_type: NodeType; - tier: MemoryTier; - salience: number; - importance: number; - activation_count: number; - embedding: number[]; -} - -export interface NodeInput { - content: string; - node_type?: NodeType; - tier?: MemoryTier; - importance?: number; - embedding?: number[]; -} - -export interface ScoredNode { - node: EngramNode; - score: number; -} - -export interface ActivatedNode { - node: EngramNode; - activation_strength: number; - hops: number; -} - -export interface ConsolidationReport { - promoted: number; - decayed: number; - pruned: number; -} diff --git a/receptors/typescript/tsconfig.json b/receptors/typescript/tsconfig.json deleted file mode 100644 index 870f2a7..0000000 --- a/receptors/typescript/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "allowJs": false, - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "pkg"] -} diff --git a/studio/index.html b/studio/index.html deleted file mode 100644 index d696a44..0000000 --- a/studio/index.html +++ /dev/null @@ -1,4261 +0,0 @@ - - - - - -Engram Studio - - - - - - - - - - -
-
-
Graph
-
Nodes
-
Timeline
-
Console
-
Swarm
-
Chat
-
- -
- - -
-
- -
-
-
Tiers
-
Working
-
Episodic
-
Semantic
-
Procedural
-
-
-
-
- - × -
-
-
-
-
-
-
- - -
-
-
-
All
-
Working
-
Episodic
-
Semantic
-
Procedural
-
- -
-
- - - - - - - - - - - - - -
ID Type Tier ContentSalience Activations Last Active
-
-
-
- - × -
-
-
-
-
-
Connections
-
-
-
-
- - -
- -
- - -
-
-
- engram> - -
-
- - - - - - - -
-
- - - - -
- - - -