use ipfrs::{Constant, Node, NodeConfig, Predicate, Rule, Term};
use std::path::PathBuf;
use std::time::Duration;
use tokio::time::sleep;
fn unique_tmp_dir(label: &str) -> PathBuf {
let pid = std::process::id();
let nonce = {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
};
std::env::temp_dir().join(format!("ipfrs_dist_{}_{}_{}", label, pid, nonce))
}
fn test_port(offset: u16) -> u16 {
let pid = (std::process::id() % 4000) as u16;
41000u16.saturating_add(pid).saturating_add(offset)
}
fn make_tensorlogic_config(dir: PathBuf, tcp_port: Option<u16>) -> NodeConfig {
let mut config = NodeConfig::default();
config.storage.path = dir.join("blocks");
config.network.data_dir = dir.join("network");
config.enable_tensorlogic = true;
config.enable_semantic = false;
config.network.enable_mdns = false;
config.network.enable_nat_traversal = false;
if let Some(port) = tcp_port {
config.network.listen_addrs = vec![format!("/ip4/127.0.0.1/tcp/{}", port)];
}
config
}
fn make_semantic_config(dir: PathBuf) -> NodeConfig {
let mut config = NodeConfig::default();
config.storage.path = dir.join("blocks");
config.network.data_dir = dir.join("network");
config.enable_tensorlogic = false;
config.enable_semantic = true;
config.network.enable_mdns = false;
config.network.enable_nat_traversal = false;
config
}
fn make_bare_config(dir: PathBuf, tcp_port: Option<u16>) -> NodeConfig {
let mut config = NodeConfig::default();
config.storage.path = dir.join("blocks");
config.network.data_dir = dir.join("network");
config.enable_tensorlogic = false;
config.enable_semantic = false;
config.network.enable_mdns = false;
config.network.enable_nat_traversal = false;
if let Some(port) = tcp_port {
config.network.listen_addrs = vec![format!("/ip4/127.0.0.1/tcp/{}", port)];
}
config
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_rule_exchange_via_bitswap() {
let port_a = test_port(0);
let port_b = test_port(2);
let dir_a = unique_tmp_dir("rule_exch_a");
let dir_b = unique_tmp_dir("rule_exch_b");
let rule_cid = {
let mut node_a =
Node::new(make_tensorlogic_config(dir_a.clone(), Some(port_a))).expect("create node A");
node_a.start().await.expect("start node A");
let fact = Predicate::new(
"parent".to_string(),
vec![
Term::Const(Constant::String("alice".to_string())),
Term::Const(Constant::String("bob".to_string())),
],
);
let rule = Rule::new(fact.clone(), vec![]);
let cid = node_a
.publish_rule(&rule)
.await
.expect("Node A: publish_rule failed");
node_a.stop().await.expect("stop node A");
cid
};
{
let mut node_b =
Node::new(make_tensorlogic_config(dir_b.clone(), Some(port_b))).expect("create node B");
node_b.start().await.expect("start node B");
{
let storage_a = ipfrs::SledBlockStore::new(ipfrs::BlockStoreConfig {
path: dir_a.join("blocks"),
cache_size: 4 * 1024 * 1024,
})
.expect("open A storage for copy");
use ipfrs::BlockStoreTrait;
if let Some(block) = storage_a.get(&rule_cid).await.expect("get block from A") {
node_b
.put_block(&block)
.await
.expect("Node B: put_block from A");
}
}
let imported = node_b
.import_rules_from_cids(&[rule_cid])
.await
.expect("import_rules_from_cids failed");
assert_eq!(imported, 1, "Expected exactly 1 rule imported");
let fact = Predicate::new(
"parent".to_string(),
vec![
Term::Const(Constant::String("alice".to_string())),
Term::Const(Constant::String("bob".to_string())),
],
);
node_b.add_fact(fact).expect("Node B: add_fact");
let goal = Predicate::new(
"parent".to_string(),
vec![
Term::Const(Constant::String("alice".to_string())),
Term::Var("X".to_string()),
],
);
let solutions = node_b.infer(&goal).expect("Node B: infer failed");
assert!(
!solutions.is_empty(),
"Expected at least one solution for parent(alice, X)"
);
let x_val = solutions[0]
.get("X")
.expect("Substitution must bind 'X'")
.to_string();
assert!(
x_val.contains("bob"),
"Expected X to be bound to 'bob', got: {}",
x_val
);
node_b.stop().await.expect("stop node B");
}
let _ = std::fs::remove_dir_all(&dir_a);
let _ = std::fs::remove_dir_all(&dir_b);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_semantic_search_two_nodes() {
let dir_a = unique_tmp_dir("sem_two_a");
let dir_b = unique_tmp_dir("sem_two_b");
let dim = {
let mut probe = Node::new(make_semantic_config(dir_a.clone())).expect("probe node");
probe.start().await.expect("probe start");
let d = probe.semantic_stats().expect("semantic_stats").dimension;
probe.stop().await.expect("probe stop");
d
};
assert!(dim > 0, "Semantic router dimension must be positive");
let snap_path_a = dir_a.join("blocks").join("hnsw_index.snap");
{
let mut node_a = Node::new(make_semantic_config(dir_a.clone())).expect("create node A");
node_a.start().await.expect("start node A");
for i in 0..10usize {
let cid = node_a
.add_bytes(format!("node_a_doc_{}", i).into_bytes())
.await
.expect("add_bytes A");
let mut embedding = vec![0.0f32; dim];
embedding[i % dim] = (i as f32 + 1.0) / 10.0;
node_a
.index_content(&cid, &embedding)
.await
.expect("index_content A");
}
let stats_a = node_a.semantic_stats().expect("semantic_stats A");
assert!(
stats_a.num_vectors >= 10,
"Node A should have >= 10 indexed vectors, got {}",
stats_a.num_vectors
);
let mut query = vec![0.0f32; dim];
query[0] = 1.0;
let results = node_a
.search_similar(&query, 5)
.await
.expect("search_similar A");
assert!(
!results.is_empty(),
"Node A local search should return at least 1 result"
);
node_a
.save_semantic_index(&snap_path_a)
.await
.expect("save_semantic_index A");
node_a.stop().await.expect("stop node A");
}
assert!(
snap_path_a.exists(),
"Node A HNSW snapshot must exist at {}",
snap_path_a.display()
);
{
let mut node_b = Node::new(make_semantic_config(dir_b.clone())).expect("create node B");
node_b.start().await.expect("start node B");
for i in 0..10usize {
let cid = node_b
.add_bytes(format!("node_b_doc_{}", i).into_bytes())
.await
.expect("add_bytes B");
let mut embedding = vec![0.0f32; dim];
let axis = (i + dim / 2) % dim;
embedding[axis] = (i as f32 + 1.0) / 10.0;
node_b
.index_content(&cid, &embedding)
.await
.expect("index_content B");
}
let stats_b = node_b.semantic_stats().expect("semantic_stats B");
assert!(
stats_b.num_vectors >= 10,
"Node B should have >= 10 indexed vectors, got {}",
stats_b.num_vectors
);
let mut query_b = vec![0.0f32; dim];
query_b[dim / 2 % dim] = 1.0;
let results_b = node_b
.search_similar(&query_b, 5)
.await
.expect("search_similar B");
assert!(
!results_b.is_empty(),
"Node B local search should return at least 1 result"
);
node_b.stop().await.expect("stop node B");
}
let _ = std::fs::remove_dir_all(&dir_a);
let _ = std::fs::remove_dir_all(&dir_b);
}
#[tokio::test]
async fn test_gradient_accumulation_single_node() {
use ipfrs_tensorlogic::gradient::{load_gradient_from_arrow, store_gradient_as_arrow};
let dir = unique_tmp_dir("grad_single");
let mut node = Node::new(make_tensorlogic_config(dir.clone(), None)).expect("create node");
node.start().await.expect("start node");
let local_grad = vec![1.0f32, 2.0, 3.0];
let ipc_bytes = store_gradient_as_arrow(&local_grad).expect("store_gradient_as_arrow failed");
use bytes::Bytes;
use ipfrs::Block;
let block = Block::new(Bytes::from(ipc_bytes.clone())).expect("Block::new failed");
let cid = *block.cid();
node.put_block(&block).await.expect("put_block failed");
let stored = node.has_block(&cid).await.expect("has_block failed");
assert!(
stored,
"Gradient block must be present in storage after put_block"
);
let retrieved_block = node
.get_block(&cid)
.await
.expect("get_block failed")
.expect("block not found");
let recovered =
load_gradient_from_arrow(retrieved_block.data()).expect("load_gradient_from_arrow failed");
assert_eq!(
recovered.len(),
local_grad.len(),
"Recovered gradient length mismatch"
);
for (i, (&orig, &rec)) in local_grad.iter().zip(recovered.iter()).enumerate() {
assert!(
(orig - rec).abs() < 1e-6,
"Gradient element {} mismatch: expected {}, got {}",
i,
orig,
rec
);
}
node.stop().await.expect("stop node");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_put_if_absent_idempotency() {
let dir = unique_tmp_dir("dedup_idem");
let mut node = Node::new(make_bare_config(dir.clone(), None)).expect("create node");
node.start().await.expect("start node");
let content = b"deduplication test content for ipfrs v0.3.0";
let cid1 = node
.add_bytes(content.as_slice())
.await
.expect("add_bytes first failed");
let cid2 = node
.add_bytes(content.as_slice())
.await
.expect("add_bytes second failed");
assert_eq!(cid1, cid2, "Both puts must yield the same CID");
let stats = node.storage_stats().expect("storage_stats failed");
assert_eq!(
stats.num_blocks, 1,
"Only 1 block should be stored despite two puts, got {}",
stats.num_blocks
);
assert_eq!(
stats.dedup.total_puts, 2,
"total_puts must be 2, got {}",
stats.dedup.total_puts
);
assert_eq!(
stats.dedup.deduplicated, 1,
"deduplicated must be 1, got {}",
stats.dedup.deduplicated
);
let data1 = node
.get(&cid1)
.await
.expect("get cid1 failed")
.expect("block not found for cid1");
let data2 = node
.get(&cid2)
.await
.expect("get cid2 failed")
.expect("block not found for cid2");
assert_eq!(data1.as_ref(), content, "data1 content mismatch");
assert_eq!(data2.as_ref(), content, "data2 content mismatch");
node.stop().await.expect("stop node");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_tensorlogic_survives_node_restart() {
let dir = unique_tmp_dir("tl_restart");
let make_fact = |name: &str, val: &str| -> Predicate {
Predicate::new(
"entity".to_string(),
vec![
Term::Const(Constant::String(name.to_string())),
Term::Const(Constant::String(val.to_string())),
],
)
};
{
let mut node =
Node::new(make_tensorlogic_config(dir.clone(), None)).expect("create node session 1");
node.start().await.expect("start session 1");
for i in 0..5usize {
node.add_fact(make_fact(&format!("entity_{}", i), &format!("value_{}", i)))
.expect("add_fact session 1");
}
let sibling_rule = Rule::new(
Predicate::new(
"sibling".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
),
vec![
Predicate::new(
"entity".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
),
Predicate::new(
"entity".to_string(),
vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
),
],
);
node.add_rule(sibling_rule).expect("add sibling_rule");
let known_rule = Rule::new(
Predicate::new("known".to_string(), vec![Term::Var("X".to_string())]),
vec![Predicate::new(
"entity".to_string(),
vec![Term::Var("X".to_string()), Term::Var("_".to_string())],
)],
);
node.add_rule(known_rule).expect("add known_rule");
let stats = node.kb_stats().expect("kb_stats session 1");
assert_eq!(stats.num_facts, 5, "Expected 5 facts before stop");
assert_eq!(stats.num_rules, 2, "Expected 2 rules before stop");
node.stop().await.expect("stop session 1");
}
{
let mut node =
Node::new(make_tensorlogic_config(dir.clone(), None)).expect("create node session 2");
node.start().await.expect("start session 2");
let stats = node.kb_stats().expect("kb_stats session 2");
assert_eq!(
stats.num_facts, 5,
"Expected 5 facts after restart, got {}",
stats.num_facts
);
assert_eq!(
stats.num_rules, 2,
"Expected 2 rules after restart, got {}",
stats.num_rules
);
let goal = Predicate::new(
"entity".to_string(),
vec![
Term::Const(Constant::String("entity_0".to_string())),
Term::Var("V".to_string()),
],
);
let solutions = node.infer(&goal).expect("infer after restart");
assert!(
!solutions.is_empty(),
"Expected at least 1 solution for entity_0 after restart"
);
node.stop().await.expect("stop session 2");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_gc_integration_preserves_active_blocks() {
let dir = unique_tmp_dir("gc_pins");
let mut node = Node::new(make_bare_config(dir.clone(), None)).expect("create node");
node.start().await.expect("start node");
let cid_a = node
.add_bytes(b"gc_block_alpha".as_slice())
.await
.expect("add block alpha");
let cid_b = node
.add_bytes(b"gc_block_beta".as_slice())
.await
.expect("add block beta");
let cid_c = node
.add_bytes(b"gc_block_gamma".as_slice())
.await
.expect("add block gamma");
node.pin_add(&cid_a, false, Some("alpha".to_string()))
.await
.expect("pin_add alpha");
node.pin_add(&cid_b, false, Some("beta".to_string()))
.await
.expect("pin_add beta");
let stats_before = node.storage_stats().expect("storage_stats before gc");
assert_eq!(
stats_before.num_blocks, 3,
"Expected 3 blocks before GC, got {}",
stats_before.num_blocks
);
let gc_result = node.gc_blocks(false, 0).await.expect("gc_blocks failed");
assert_eq!(
gc_result.collected, 1,
"GC should collect exactly 1 unpinned block, got {}",
gc_result.collected
);
let data_a = node
.get(&cid_a)
.await
.expect("get cid_a after gc failed")
.expect("cid_a not found after GC");
assert_eq!(
data_a.as_ref(),
b"gc_block_alpha",
"cid_a data corrupted after GC"
);
let data_b = node
.get(&cid_b)
.await
.expect("get cid_b after gc failed")
.expect("cid_b not found after GC");
assert_eq!(
data_b.as_ref(),
b"gc_block_beta",
"cid_b data corrupted after GC"
);
let cid_c_exists = node.has_block(&cid_c).await.expect("has_block cid_c");
assert!(
!cid_c_exists,
"cid_c (unpinned) should have been collected by GC"
);
node.stop().await.expect("stop node");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_prove_distributed_local_chain() {
let dir = unique_tmp_dir("proof_dist");
let mut node = Node::new(make_tensorlogic_config(dir.clone(), None)).expect("create node");
node.start().await.expect("start node");
node.add_fact(Predicate::new(
"parent".to_string(),
vec![
Term::Const(Constant::String("alice".to_string())),
Term::Const(Constant::String("bob".to_string())),
],
))
.expect("add parent(alice, bob)");
node.add_fact(Predicate::new(
"parent".to_string(),
vec![
Term::Const(Constant::String("bob".to_string())),
Term::Const(Constant::String("charlie".to_string())),
],
))
.expect("add parent(bob, charlie)");
node.add_rule(Rule::new(
Predicate::new(
"ancestor".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
),
vec![Predicate::new(
"parent".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
)],
))
.expect("add ancestor base rule");
node.add_rule(Rule::new(
Predicate::new(
"ancestor".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
),
vec![
Predicate::new(
"parent".to_string(),
vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
),
Predicate::new(
"ancestor".to_string(),
vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
),
],
))
.expect("add ancestor inductive rule");
let proof_tree = node
.prove_distributed("?- ancestor(alice, charlie).", 5)
.await
.expect("prove_distributed failed");
assert!(
proof_tree.is_complete,
"ProofTree must be complete for ancestor(alice, charlie) with depth=5"
);
let max_d = proof_tree.max_depth();
assert!(
max_d >= 2,
"ProofTree max_depth should be >= 2 (alice→bob→charlie chain), got {}",
max_d
);
node.stop().await.expect("stop node");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_sanity_guard() {
sleep(Duration::from_millis(1)).await;
}