rename crates/ to engrams/, bindings/ to receptors/

- crates/ → engrams/ (Rust engrams live here)
- bindings/ → receptors/ (cross-language access points into the graph)
- Cargo.toml workspace paths updated
This commit is contained in:
Will Anderson
2026-04-29 03:27:33 -05:00
parent 61a4632163
commit 909c1577f1
89 changed files with 2114 additions and 452 deletions
+24
View File
@@ -0,0 +1,24 @@
[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"] }
+122
View File
@@ -0,0 +1,122 @@
/// 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<SyncDelta> {
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::<SyncDelta>()
.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<Vec<SerializableActivatedNode>> {
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
}
}
+423
View File
@@ -0,0 +1,423 @@
/// 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<Mutex<EngramDb>>,
peers: Vec<Peer>,
our_id: Uuid,
our_name: String,
api_key: String,
default_sync_tiers: Vec<MemoryTier>,
}
impl SyncEngine {
pub fn new(db: Arc<Mutex<EngramDb>>, 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<Peer> = 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<PeerSyncResult> {
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<SyncDelta> {
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<Uuid> = 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<usize> {
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<SwarmActivateResponse> {
// Local activation
let local_results: Vec<SerializableActivatedNode> = {
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<PeerActivationResult> = Vec::new();
if req.include_peers {
// Fan out to all trusted peers in parallel
let trusted_peers: Vec<Peer> = 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<PeerStatus> {
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<MergedActivatedNode> {
use std::collections::HashMap;
// (uuid -> MergedActivatedNode) — we keep the strongest instance of each node
let mut map: HashMap<Uuid, MergedActivatedNode> = 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<MergedActivatedNode> = 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<MemoryTier> {
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()
}
}
+265
View File
@@ -0,0 +1,265 @@
/// 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<SerializableActivatedNode> = 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<SerializableActivatedNode> = (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<SerializableActivatedNode> = strengths
.iter()
.map(|&s| make_activated(make_node(MemoryTier::Semantic), s, 1))
.collect();
let merged = merge_activation_results(&local, &[], 10);
let result_strengths: Vec<f32> = 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);
}
}
+166
View File
@@ -0,0 +1,166 @@
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<MemoryTier>,
/// 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<Node>,
/// Edges added or modified since `since`
pub edges: Vec<Edge>,
/// Node IDs that were deleted (tombstones) — receivers should remove them
pub tombstones: Vec<Uuid>,
/// 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<Uuid>,
/// Query embedding for semantic scoring
pub query_embedding: Vec<f32>,
/// 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<SerializableActivatedNode>,
pub peer_results: Vec<PeerActivationResult>,
/// Deduplicated, re-ranked unified results from all sources
pub merged: Vec<MergedActivatedNode>,
}
/// 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<ActivatedNode> 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<SerializableActivatedNode>,
/// Set if the peer request failed
pub error: Option<String>,
}
/// 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<Uuid>,
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<String>,
}
/// 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<MemoryTier>,
/// 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<MemoryTier>,
pub trusted: bool,
}