From 65047713f7caaeecc2b6caa5ca5a2adeccfaabcc Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 03:25:10 -0500 Subject: [PATCH] remove Rust workspace; El implementation is the canonical engram Deletes the entire Rust first-pass: Cargo workspace, 10 crates, engram-data/, engram-data-tx-log/, receptors/, studio/, and examples/. Keeps: src/server.el, manifest.el, dist/, spec/, README.md, engram-explainer.html. --- Cargo.lock | 3185 ------------ Cargo.toml | 37 - engram-data-tx-log/conf | 4 - engram-data-tx-log/db | Bin 524287 -> 0 bytes engram-data-tx-log/snap.0000000000000060 | Bin 69 -> 0 bytes engram-data/conf | 4 - engram-data/db | Bin 524287 -> 0 bytes engram-data/snap.0000000000035912 | Bin 596 -> 0 bytes engrams/engram-core/Cargo.toml | 26 - engrams/engram-core/src/activation.rs | 318 -- engrams/engram-core/src/consolidation.rs | 308 -- engrams/engram-core/src/db.rs | 549 --- engrams/engram-core/src/edge_type.rs | 443 -- engrams/engram-core/src/error.rs | 27 - engrams/engram-core/src/graph.rs | 98 - engrams/engram-core/src/lib.rs | 57 - engrams/engram-core/src/mem_storage.rs | 99 - engrams/engram-core/src/migration.rs | 423 -- engrams/engram-core/src/salience.rs | 47 - engrams/engram-core/src/storage.rs | 202 - engrams/engram-core/src/types.rs | 228 - engrams/engram-core/src/vector.rs | 330 -- engrams/engram-crypto/Cargo.toml | 34 - engrams/engram-crypto/src/algorithm.rs | 87 - engrams/engram-crypto/src/engine.rs | 340 -- engrams/engram-crypto/src/error.rs | 30 - engrams/engram-crypto/src/lib.rs | 43 - engrams/engram-crypto/src/registry.rs | 95 - engrams/engram-ffi/Cargo.toml | 16 - engrams/engram-ffi/src/lib.rs | 469 -- engrams/engram-jni/Cargo.toml | 19 - engrams/engram-jni/src/lib.rs | 496 -- engrams/engram-migrate/Cargo.toml | 13 - engrams/engram-migrate/src/main.rs | 105 - engrams/engram-projection/Cargo.toml | 17 - engrams/engram-projection/src/engine.rs | 463 -- engrams/engram-projection/src/error.rs | 24 - engrams/engram-projection/src/lib.rs | 33 - engrams/engram-projection/src/registry.rs | 133 - engrams/engram-projection/src/schema.rs | 133 - engrams/engram-reasoning/Cargo.toml | 16 - engrams/engram-reasoning/src/engine.rs | 1013 ---- engrams/engram-reasoning/src/lib.rs | 50 - engrams/engram-reasoning/src/tests.rs | 531 -- engrams/engram-reasoning/src/types.rs | 247 - engrams/engram-server/Cargo.toml | 37 - engrams/engram-server/src/auth.rs | 31 - engrams/engram-server/src/main.rs | 287 -- engrams/engram-server/src/routes/core.rs | 246 - engrams/engram-server/src/routes/mod.rs | 6 - .../engram-server/src/routes/projection.rs | 121 - engrams/engram-server/src/routes/reasoning.rs | 177 - engrams/engram-server/src/routes/swarm.rs | 124 - engrams/engram-server/src/routes/sync.rs | 133 - engrams/engram-server/src/routes/tx.rs | 109 - engrams/engram-server/src/state.rs | 19 - engrams/engram-sync/Cargo.toml | 24 - engrams/engram-sync/src/client.rs | 122 - engrams/engram-sync/src/engine.rs | 423 -- engrams/engram-sync/src/lib.rs | 265 - engrams/engram-sync/src/types.rs | 166 - engrams/engram-tx/Cargo.toml | 17 - engrams/engram-tx/src/command.rs | 166 - engrams/engram-tx/src/engine.rs | 682 --- engrams/engram-tx/src/error.rs | 31 - engrams/engram-tx/src/lib.rs | 29 - engrams/engram-tx/src/log.rs | 151 - examples/basic.rs | 265 - examples/migrate.rs | 96 - receptors/go/README.md | 25 - receptors/go/engram.go | 197 - receptors/go/engram.h | 70 - receptors/go/engram_test.go | 130 - receptors/go/go.mod | 3 - receptors/kotlin/README.md | 21 - receptors/kotlin/build.gradle.kts | 33 - receptors/kotlin/settings.gradle.kts | 1 - .../kotlin/ai/neuron/engram/ActivatedNode.kt | 13 - .../main/kotlin/ai/neuron/engram/EngramDb.kt | 153 - .../kotlin/ai/neuron/engram/EngramEdge.kt | 14 - .../kotlin/ai/neuron/engram/EngramNode.kt | 38 - .../kotlin/ai/neuron/engram/EngramTypes.kt | 31 - receptors/typescript/Cargo.toml | 24 - receptors/typescript/README.md | 21 - receptors/typescript/package.json | 21 - receptors/typescript/src/index.ts | 125 - receptors/typescript/src/lib.rs | 311 -- receptors/typescript/src/types.ts | 49 - receptors/typescript/tsconfig.json | 18 - studio/index.html | 4261 ----------------- 90 files changed, 20078 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 engram-data-tx-log/conf delete mode 100644 engram-data-tx-log/db delete mode 100644 engram-data-tx-log/snap.0000000000000060 delete mode 100644 engram-data/conf delete mode 100644 engram-data/db delete mode 100644 engram-data/snap.0000000000035912 delete mode 100644 engrams/engram-core/Cargo.toml delete mode 100644 engrams/engram-core/src/activation.rs delete mode 100644 engrams/engram-core/src/consolidation.rs delete mode 100644 engrams/engram-core/src/db.rs delete mode 100644 engrams/engram-core/src/edge_type.rs delete mode 100644 engrams/engram-core/src/error.rs delete mode 100644 engrams/engram-core/src/graph.rs delete mode 100644 engrams/engram-core/src/lib.rs delete mode 100644 engrams/engram-core/src/mem_storage.rs delete mode 100644 engrams/engram-core/src/migration.rs delete mode 100644 engrams/engram-core/src/salience.rs delete mode 100644 engrams/engram-core/src/storage.rs delete mode 100644 engrams/engram-core/src/types.rs delete mode 100644 engrams/engram-core/src/vector.rs delete mode 100644 engrams/engram-crypto/Cargo.toml delete mode 100644 engrams/engram-crypto/src/algorithm.rs delete mode 100644 engrams/engram-crypto/src/engine.rs delete mode 100644 engrams/engram-crypto/src/error.rs delete mode 100644 engrams/engram-crypto/src/lib.rs delete mode 100644 engrams/engram-crypto/src/registry.rs delete mode 100644 engrams/engram-ffi/Cargo.toml delete mode 100644 engrams/engram-ffi/src/lib.rs delete mode 100644 engrams/engram-jni/Cargo.toml delete mode 100644 engrams/engram-jni/src/lib.rs delete mode 100644 engrams/engram-migrate/Cargo.toml delete mode 100644 engrams/engram-migrate/src/main.rs delete mode 100644 engrams/engram-projection/Cargo.toml delete mode 100644 engrams/engram-projection/src/engine.rs delete mode 100644 engrams/engram-projection/src/error.rs delete mode 100644 engrams/engram-projection/src/lib.rs delete mode 100644 engrams/engram-projection/src/registry.rs delete mode 100644 engrams/engram-projection/src/schema.rs delete mode 100644 engrams/engram-reasoning/Cargo.toml delete mode 100644 engrams/engram-reasoning/src/engine.rs delete mode 100644 engrams/engram-reasoning/src/lib.rs delete mode 100644 engrams/engram-reasoning/src/tests.rs delete mode 100644 engrams/engram-reasoning/src/types.rs delete mode 100644 engrams/engram-server/Cargo.toml delete mode 100644 engrams/engram-server/src/auth.rs delete mode 100644 engrams/engram-server/src/main.rs delete mode 100644 engrams/engram-server/src/routes/core.rs delete mode 100644 engrams/engram-server/src/routes/mod.rs delete mode 100644 engrams/engram-server/src/routes/projection.rs delete mode 100644 engrams/engram-server/src/routes/reasoning.rs delete mode 100644 engrams/engram-server/src/routes/swarm.rs delete mode 100644 engrams/engram-server/src/routes/sync.rs delete mode 100644 engrams/engram-server/src/routes/tx.rs delete mode 100644 engrams/engram-server/src/state.rs delete mode 100644 engrams/engram-sync/Cargo.toml delete mode 100644 engrams/engram-sync/src/client.rs delete mode 100644 engrams/engram-sync/src/engine.rs delete mode 100644 engrams/engram-sync/src/lib.rs delete mode 100644 engrams/engram-sync/src/types.rs delete mode 100644 engrams/engram-tx/Cargo.toml delete mode 100644 engrams/engram-tx/src/command.rs delete mode 100644 engrams/engram-tx/src/engine.rs delete mode 100644 engrams/engram-tx/src/error.rs delete mode 100644 engrams/engram-tx/src/lib.rs delete mode 100644 engrams/engram-tx/src/log.rs delete mode 100644 examples/basic.rs delete mode 100644 examples/migrate.rs delete mode 100644 receptors/go/README.md delete mode 100644 receptors/go/engram.go delete mode 100644 receptors/go/engram.h delete mode 100644 receptors/go/engram_test.go delete mode 100644 receptors/go/go.mod delete mode 100644 receptors/kotlin/README.md delete mode 100644 receptors/kotlin/build.gradle.kts delete mode 100644 receptors/kotlin/settings.gradle.kts delete mode 100644 receptors/kotlin/src/main/kotlin/ai/neuron/engram/ActivatedNode.kt delete mode 100644 receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramDb.kt delete mode 100644 receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramEdge.kt delete mode 100644 receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramNode.kt delete mode 100644 receptors/kotlin/src/main/kotlin/ai/neuron/engram/EngramTypes.kt delete mode 100644 receptors/typescript/Cargo.toml delete mode 100644 receptors/typescript/README.md delete mode 100644 receptors/typescript/package.json delete mode 100644 receptors/typescript/src/index.ts delete mode 100644 receptors/typescript/src/lib.rs delete mode 100644 receptors/typescript/src/types.ts delete mode 100644 receptors/typescript/tsconfig.json delete mode 100644 studio/index.html 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 c4d313c7775b17c65ef6795ff695c1888ed4573d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 524287 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

|G5w$b)f?P#zG5=^ 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 diff --git a/engram-data/snap.0000000000035912 b/engram-data/snap.0000000000035912 deleted file mode 100644 index 40126d0d2af02239bae20617660eb1ed705d37b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmX}pze~eF7{>8jRB8q-(nX;|M?s6Ao801*Mmvd!;^^We`XBfM#L3M?sx4S>Ds-sh zkq+5J5J5o~LEY-02rpcO;-l?z8IsSJa4(ljZhgS}yOaw5pSXn+Do^Kw!9vFGRSHR~ z9`+V^DHInX78F+&Sx~H!5w3)`gUS;VvSX{)DI3!JifbW#FwBDVMV1BW-Dwu2kLOsB zo>^c)5}hwIA&G{b-5|*3t{QF>B%Q>~f~2$MJ{8iBYn%mXUshEASRgxjSv_Mz5(V`W zejQ|s-d)0mq}9A(Lh|0eXF~Gv`ptyo?aQ4MBsJrX3X+HFJU0eOZM@8cu!smN59O0n9u+K 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> - -
-
- - - - - - - -
-
- - - - -
- - - -