use base64::Engine;
use crate::blind;
use crate::crypto;
use crate::fingerprint;
use crate::lsh::LshHasher;
use crate::protobuf::{self, FactPayload};
#[cfg(feature = "managed")]
use crate::userop;
use crate::Result;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreparedFact {
pub fact_id: String,
pub timestamp: String,
pub owner: String,
pub encrypted_blob_hex: String,
pub blind_indices: Vec<String>,
pub decay_score: f64,
pub source: String,
pub content_fp: String,
pub agent_id: String,
pub encrypted_embedding: Option<String>,
pub protobuf_bytes: Vec<u8>,
}
pub fn prepare_fact(
text: &str,
encryption_key: &[u8; 32],
dedup_key: &[u8; 32],
lsh_hasher: &LshHasher,
embedding: &[f32],
importance: f64,
source: &str,
owner: &str,
agent_id: &str,
) -> Result<PreparedFact> {
let fact_id = uuid::Uuid::now_v7().to_string();
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let envelope = serde_json::json!({
"t": text,
"a": agent_id,
"s": source,
});
let encrypted_blob_b64 = crypto::encrypt(&envelope.to_string(), encryption_key)?;
let encrypted_blob_bytes = base64::engine::general_purpose::STANDARD
.decode(&encrypted_blob_b64)
.map_err(|e| crate::Error::Crypto(e.to_string()))?;
let encrypted_blob_hex = hex::encode(&encrypted_blob_bytes);
let mut blind_indices = blind::generate_blind_indices(text);
let embedding_f64: Vec<f64> = embedding.iter().map(|&f| f as f64).collect();
let lsh_buckets = lsh_hasher.hash(&embedding_f64)?;
blind_indices.extend(lsh_buckets);
let content_fp = fingerprint::generate_content_fingerprint(text, dedup_key);
let encrypted_embedding = encrypt_embedding(embedding, encryption_key)?;
let decay_score = (importance / 10.0).clamp(0.0, 1.0);
let payload = FactPayload {
id: fact_id.clone(),
timestamp: timestamp.clone(),
owner: owner.to_string(),
encrypted_blob_hex: encrypted_blob_hex.clone(),
blind_indices: blind_indices.clone(),
decay_score,
source: source.to_string(),
content_fp: content_fp.clone(),
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding.clone()),
version: protobuf::DEFAULT_PROTOBUF_VERSION,
};
let protobuf_bytes = protobuf::encode_fact_protobuf(&payload);
Ok(PreparedFact {
fact_id,
timestamp,
owner: owner.to_string(),
encrypted_blob_hex,
blind_indices,
decay_score,
source: source.to_string(),
content_fp,
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding),
protobuf_bytes,
})
}
pub fn prepare_fact_with_decay_score(
text: &str,
encryption_key: &[u8; 32],
dedup_key: &[u8; 32],
lsh_hasher: &LshHasher,
embedding: &[f32],
decay_score: f64,
source: &str,
owner: &str,
agent_id: &str,
) -> Result<PreparedFact> {
let fact_id = uuid::Uuid::now_v7().to_string();
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let envelope = serde_json::json!({
"t": text,
"a": agent_id,
"s": source,
});
let encrypted_blob_b64 = crypto::encrypt(&envelope.to_string(), encryption_key)?;
let encrypted_blob_bytes = base64::engine::general_purpose::STANDARD
.decode(&encrypted_blob_b64)
.map_err(|e| crate::Error::Crypto(e.to_string()))?;
let encrypted_blob_hex = hex::encode(&encrypted_blob_bytes);
let mut blind_indices = blind::generate_blind_indices(text);
let embedding_f64: Vec<f64> = embedding.iter().map(|&f| f as f64).collect();
let lsh_buckets = lsh_hasher.hash(&embedding_f64)?;
blind_indices.extend(lsh_buckets);
let content_fp = fingerprint::generate_content_fingerprint(text, dedup_key);
let encrypted_embedding = encrypt_embedding(embedding, encryption_key)?;
let clamped_decay = decay_score.clamp(0.0, 1.0);
let payload = FactPayload {
id: fact_id.clone(),
timestamp: timestamp.clone(),
owner: owner.to_string(),
encrypted_blob_hex: encrypted_blob_hex.clone(),
blind_indices: blind_indices.clone(),
decay_score: clamped_decay,
source: source.to_string(),
content_fp: content_fp.clone(),
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding.clone()),
version: protobuf::DEFAULT_PROTOBUF_VERSION,
};
let protobuf_bytes = protobuf::encode_fact_protobuf(&payload);
Ok(PreparedFact {
fact_id,
timestamp,
owner: owner.to_string(),
encrypted_blob_hex,
blind_indices,
decay_score: clamped_decay,
source: source.to_string(),
content_fp,
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding),
protobuf_bytes,
})
}
#[cfg(feature = "managed")]
pub fn build_single_calldata(prepared: &PreparedFact) -> Vec<u8> {
userop::encode_single_call(&prepared.protobuf_bytes)
}
#[cfg(feature = "managed")]
pub fn build_batch_calldata(prepared: &[PreparedFact]) -> Result<Vec<u8>> {
let payloads: Vec<Vec<u8>> = prepared.iter().map(|p| p.protobuf_bytes.clone()).collect();
userop::encode_batch_call(&payloads)
}
pub fn prepare_tombstone(fact_id: &str, owner: &str) -> Vec<u8> {
protobuf::encode_tombstone_protobuf(fact_id, owner, protobuf::DEFAULT_PROTOBUF_VERSION)
}
pub fn prepare_tombstone_v1(fact_id: &str, owner: &str) -> Vec<u8> {
protobuf::encode_tombstone_protobuf(fact_id, owner, protobuf::PROTOBUF_VERSION_V4)
}
pub fn prepare_fact_v1(
envelope_json: &str,
text_for_blind_indices: &str,
encryption_key: &[u8; 32],
dedup_key: &[u8; 32],
lsh_hasher: &LshHasher,
embedding: &[f32],
importance: f64,
source: &str,
owner: &str,
agent_id: &str,
) -> Result<PreparedFact> {
let fact_id = uuid::Uuid::now_v7().to_string();
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let encrypted_blob_b64 = crypto::encrypt(envelope_json, encryption_key)?;
let encrypted_blob_bytes = base64::engine::general_purpose::STANDARD
.decode(&encrypted_blob_b64)
.map_err(|e| crate::Error::Crypto(e.to_string()))?;
let encrypted_blob_hex = hex::encode(&encrypted_blob_bytes);
let mut blind_indices = blind::generate_blind_indices(text_for_blind_indices);
let embedding_f64: Vec<f64> = embedding.iter().map(|&f| f as f64).collect();
let lsh_buckets = lsh_hasher.hash(&embedding_f64)?;
blind_indices.extend(lsh_buckets);
let content_fp = fingerprint::generate_content_fingerprint(text_for_blind_indices, dedup_key);
let encrypted_embedding = encrypt_embedding(embedding, encryption_key)?;
let decay_score = (importance / 10.0).clamp(0.0, 1.0);
let payload = FactPayload {
id: fact_id.clone(),
timestamp: timestamp.clone(),
owner: owner.to_string(),
encrypted_blob_hex: encrypted_blob_hex.clone(),
blind_indices: blind_indices.clone(),
decay_score,
source: source.to_string(),
content_fp: content_fp.clone(),
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding.clone()),
version: protobuf::PROTOBUF_VERSION_V4,
};
let protobuf_bytes = protobuf::encode_fact_protobuf(&payload);
Ok(PreparedFact {
fact_id,
timestamp,
owner: owner.to_string(),
encrypted_blob_hex,
blind_indices,
decay_score,
source: source.to_string(),
content_fp,
agent_id: agent_id.to_string(),
encrypted_embedding: Some(encrypted_embedding),
protobuf_bytes,
})
}
pub fn compute_content_fingerprint(text: &str, dedup_key: &[u8; 32]) -> String {
fingerprint::generate_content_fingerprint(text, dedup_key)
}
fn encrypt_embedding(embedding: &[f32], encryption_key: &[u8; 32]) -> Result<String> {
let emb_bytes: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
let emb_b64 = base64::engine::general_purpose::STANDARD.encode(&emb_bytes);
crypto::encrypt(&emb_b64, encryption_key)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsh::LshHasher;
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
fn test_keys() -> (crypto::DerivedKeys, LshHasher) {
let keys = crypto::derive_keys_from_mnemonic(TEST_MNEMONIC).unwrap();
let lsh_seed = crypto::derive_lsh_seed(TEST_MNEMONIC, &keys.salt).unwrap();
let lsh_hasher = LshHasher::new(&lsh_seed, 640).unwrap();
(keys, lsh_hasher)
}
fn dummy_embedding() -> Vec<f32> {
let mut emb = vec![0.0f32; 640];
emb[0] = 1.0;
emb
}
#[test]
fn test_prepare_fact_returns_valid_struct() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"User prefers dark mode",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"auto_extraction",
"0xABCD1234",
"test_agent",
)
.unwrap();
assert!(uuid::Uuid::parse_str(&prepared.fact_id).is_ok());
assert!(prepared.timestamp.contains('T'));
assert!(prepared.timestamp.ends_with('Z'));
assert_eq!(prepared.owner, "0xABCD1234");
assert_eq!(prepared.source, "auto_extraction");
assert_eq!(prepared.agent_id, "test_agent");
assert!((prepared.decay_score - 0.8).abs() < 1e-10);
assert!(!prepared.encrypted_blob_hex.is_empty());
assert!(hex::decode(&prepared.encrypted_blob_hex).is_ok());
assert!(prepared.blind_indices.len() > 20, "Should have word hashes + 20 LSH buckets");
assert_eq!(prepared.content_fp.len(), 64);
assert!(hex::decode(&prepared.content_fp).is_ok());
assert!(prepared.encrypted_embedding.is_some());
assert!(!prepared.protobuf_bytes.is_empty());
assert!(prepared.protobuf_bytes.windows(prepared.fact_id.len())
.any(|w| w == prepared.fact_id.as_bytes()));
}
#[test]
fn test_prepare_fact_importance_normalization() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let cases = vec![
(0.0, 0.0),
(1.0, 0.1),
(5.0, 0.5),
(8.0, 0.8),
(10.0, 1.0),
(15.0, 1.0), (-5.0, 0.0), ];
for (importance, expected_decay) in cases {
let prepared = prepare_fact(
"test",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
importance,
"test",
"0xABCD",
"test",
)
.unwrap();
assert!(
(prepared.decay_score - expected_decay).abs() < 1e-10,
"importance {} should produce decay_score {}, got {}",
importance,
expected_decay,
prepared.decay_score,
);
}
}
#[test]
fn test_prepare_fact_with_decay_score() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact_with_decay_score(
"User prefers dark mode",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
1.0,
"zeroclaw_fact",
"0xABCD",
"zeroclaw",
)
.unwrap();
assert!((prepared.decay_score - 1.0).abs() < 1e-10);
}
#[test]
fn test_prepare_fact_content_fingerprint_deterministic() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let p1 = prepare_fact(
"User prefers dark mode",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let p2 = prepare_fact(
"User prefers dark mode",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
assert_eq!(p1.content_fp, p2.content_fp);
assert_ne!(p1.fact_id, p2.fact_id);
}
#[test]
fn test_prepare_fact_encrypted_content_decryptable() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"Secret fact content",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let encrypted_bytes = hex::decode(&prepared.encrypted_blob_hex).unwrap();
let encrypted_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted_bytes);
let decrypted = crypto::decrypt(&encrypted_b64, &keys.encryption_key).unwrap();
let envelope: serde_json::Value = serde_json::from_str(&decrypted).unwrap();
assert_eq!(envelope["t"].as_str().unwrap(), "Secret fact content");
}
#[test]
fn test_prepare_fact_encrypted_content_has_envelope() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"Secret fact content",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"conversation",
"0xABCD1234",
"hermes-agent",
)
.unwrap();
let encrypted_bytes = hex::decode(&prepared.encrypted_blob_hex).unwrap();
let encrypted_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted_bytes);
let decrypted = crypto::decrypt(&encrypted_b64, &keys.encryption_key).unwrap();
let envelope: serde_json::Value = serde_json::from_str(&decrypted).unwrap();
assert_eq!(envelope["t"].as_str().unwrap(), "Secret fact content");
assert_eq!(envelope["a"].as_str().unwrap(), "hermes-agent");
assert_eq!(envelope["s"].as_str().unwrap(), "conversation");
}
#[test]
fn test_prepare_fact_encrypted_embedding_decryptable() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"test",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let enc_emb = prepared.encrypted_embedding.as_ref().unwrap();
let decrypted_b64 = crypto::decrypt(enc_emb, &keys.encryption_key).unwrap();
let emb_bytes = base64::engine::general_purpose::STANDARD
.decode(&decrypted_b64)
.unwrap();
let recovered: Vec<f32> = emb_bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
assert_eq!(recovered.len(), 640);
assert!((recovered[0] - 1.0).abs() < 1e-6);
assert!((recovered[1] - 0.0).abs() < 1e-6);
}
#[test]
fn test_prepare_tombstone() {
let tombstone_bytes = prepare_tombstone("test-fact-id", "0xABCD");
assert!(!tombstone_bytes.is_empty());
assert!(tombstone_bytes
.windows("test-fact-id".len())
.any(|w| w == b"test-fact-id"));
}
#[test]
fn test_compute_content_fingerprint() {
let key = [0u8; 32];
let fp = compute_content_fingerprint("hello world", &key);
assert_eq!(fp.len(), 64);
let fp2 = compute_content_fingerprint("hello world", &key);
assert_eq!(fp, fp2);
let fp3 = compute_content_fingerprint("hello mars", &key);
assert_ne!(fp, fp3);
}
#[cfg(feature = "managed")]
mod managed_tests {
use super::*;
#[test]
fn test_build_single_calldata() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"test fact",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let calldata = build_single_calldata(&prepared);
assert_eq!(&calldata[..4], &[0xb6, 0x1d, 0x27, 0xf6]);
assert!(calldata.len() > 100);
}
#[test]
fn test_build_batch_calldata() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let p1 = prepare_fact(
"fact one",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let p2 = prepare_fact(
"fact two",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
6.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let calldata = build_batch_calldata(&[p1, p2]).unwrap();
assert_eq!(&calldata[..4], &[0x47, 0xe1, 0xda, 0x2a]);
}
#[test]
fn test_build_batch_single_uses_execute() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let p1 = prepare_fact(
"single fact",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let calldata = build_batch_calldata(&[p1]).unwrap();
assert_eq!(&calldata[..4], &[0xb6, 0x1d, 0x27, 0xf6]);
}
#[test]
fn test_build_single_matches_direct_userop() {
let (keys, lsh_hasher) = test_keys();
let emb = dummy_embedding();
let prepared = prepare_fact(
"parity test",
&keys.encryption_key,
&keys.dedup_key,
&lsh_hasher,
&emb,
8.0,
"test",
"0xABCD",
"test",
)
.unwrap();
let via_store = build_single_calldata(&prepared);
let via_userop = userop::encode_single_call(&prepared.protobuf_bytes);
assert_eq!(via_store, via_userop);
}
}
}