Skip to main content

totalreclaw_memory/
store.rs

1//! Store pipeline -- encrypt, index, encode, submit.
2//!
3//! Orchestrates the full fact storage pipeline:
4//! text -> embed -> encrypt -> blind indices + LSH -> protobuf -> relay submission.
5//!
6//! Uses `totalreclaw-core::store::prepare_fact()` for the pure computation phase
7//! (encrypt, index, encode) and handles the I/O phase (dedup checks, relay
8//! submission) in this crate.
9//!
10//! Includes store-time near-duplicate detection via
11//! `totalreclaw_core::consolidation::find_best_near_duplicate` (cosine >= 0.85).
12//!
13//! Phase 2 KG support: `store_claim_with_contradiction_check` runs the full
14//! `totalreclaw_core::contradiction::resolve_with_candidates` pipeline against
15//! pre-fetched candidates before storing a canonical `Claim`.
16
17use base64::Engine;
18
19use totalreclaw_core::blind;
20use totalreclaw_core::claims::{
21    Claim, MemoryClaimV1, MemoryScope, MemorySource, MemoryTypeV1, MemoryVolatility,
22    ResolutionAction, MEMORY_CLAIM_V1_SCHEMA_VERSION,
23};
24use totalreclaw_core::consolidation;
25use totalreclaw_core::contradiction;
26use totalreclaw_core::crypto;
27use totalreclaw_core::decision_log;
28use totalreclaw_core::fingerprint;
29use totalreclaw_core::lsh::LshHasher;
30use totalreclaw_core::store as core_store;
31
32use crate::embedding::EmbeddingProvider;
33use crate::relay::RelayClient;
34use crate::search;
35use crate::Result;
36
37/// Store a fact on-chain via native UserOp submission.
38///
39/// Full pipeline:
40/// 1. Content fingerprint (exact dedup)
41/// 2. Check fingerprint against existing (supersede if exact match)
42/// 3. Generate embedding
43/// 4. Store-time best-match dedup (supersede if cosine >= 0.85)
44/// 5. Prepare fact via core (encrypt, index, encode protobuf)
45/// 6. Submit via native UserOp (or legacy if no private key)
46pub async fn store_fact(
47    content: &str,
48    source: &str,
49    keys: &crypto::DerivedKeys,
50    lsh_hasher: &LshHasher,
51    embedding_provider: &dyn EmbeddingProvider,
52    relay: &RelayClient,
53    private_key: Option<&[u8; 32]>,
54) -> Result<String> {
55    // 1. Content fingerprint (used for exact dedup)
56    let content_fp = fingerprint::generate_content_fingerprint(content, &keys.dedup_key);
57
58    // 2. Check for exact duplicate: search by content fingerprint
59    //    If an existing fact has the same fingerprint, tombstone it (supersede).
60    if let Ok(existing) =
61        search::search_by_fingerprint(relay, relay.wallet_address(), &content_fp).await
62    {
63        if let Some(dup) = existing {
64            // Exact duplicate found -- supersede it with a tombstone
65            let _ = store_tombstone(&dup.id, relay, private_key).await;
66        }
67    }
68
69    // 3. Generate embedding
70    let embedding = embedding_provider.embed(content).await?;
71
72    // 4. Store-time best-match dedup: find highest-similarity near-duplicate
73    if let Some(dup) = find_best_duplicate(content, &embedding, keys, relay).await {
74        // Near-duplicate found -- tombstone and supersede (same behavior as TS consolidation.ts)
75        let _ = store_tombstone(&dup.fact_id, relay, private_key).await;
76    }
77
78    // 5. Prepare fact via core (encrypt, index, encode protobuf)
79    let prepared = core_store::prepare_fact_with_decay_score(
80        content,
81        &keys.encryption_key,
82        &keys.dedup_key,
83        lsh_hasher,
84        &embedding,
85        1.0, // default decay_score
86        source,
87        relay.wallet_address(),
88        "zeroclaw",
89    )
90    .map_err(|e| crate::Error::Crypto(e.to_string()))?;
91
92    // 6. Submit
93    if let Some(pk) = private_key {
94        relay
95            .submit_fact_native(&prepared.protobuf_bytes, pk)
96            .await?;
97    } else {
98        relay.submit_protobuf(&prepared.protobuf_bytes).await?;
99    }
100
101    Ok(prepared.fact_id)
102}
103
104/// Store a fact with a specific importance value (0.0 - 1.0).
105///
106/// The importance is normalized per spec: `decayScore = importance / 10`.
107/// Input is on a 1-10 scale, stored as 0.0-1.0.
108pub async fn store_fact_with_importance(
109    content: &str,
110    source: &str,
111    importance: f64,
112    keys: &crypto::DerivedKeys,
113    lsh_hasher: &LshHasher,
114    embedding_provider: &dyn EmbeddingProvider,
115    relay: &RelayClient,
116    private_key: Option<&[u8; 32]>,
117) -> Result<String> {
118    // Content fingerprint
119    let content_fp = fingerprint::generate_content_fingerprint(content, &keys.dedup_key);
120
121    // Exact dedup check
122    if let Ok(existing) =
123        search::search_by_fingerprint(relay, relay.wallet_address(), &content_fp).await
124    {
125        if let Some(dup) = existing {
126            let _ = store_tombstone(&dup.id, relay, private_key).await;
127        }
128    }
129
130    // Generate embedding
131    let embedding = embedding_provider.embed(content).await?;
132
133    // Best-match near-duplicate check: supersede if found
134    if let Some(dup) = find_best_duplicate(content, &embedding, keys, relay).await {
135        let _ = store_tombstone(&dup.fact_id, relay, private_key).await;
136    }
137
138    // Prepare fact via core (with importance normalization)
139    let prepared = core_store::prepare_fact(
140        content,
141        &keys.encryption_key,
142        &keys.dedup_key,
143        lsh_hasher,
144        &embedding,
145        importance,
146        source,
147        relay.wallet_address(),
148        "zeroclaw",
149    )
150    .map_err(|e| crate::Error::Crypto(e.to_string()))?;
151
152    // Submit
153    if let Some(pk) = private_key {
154        relay
155            .submit_fact_native(&prepared.protobuf_bytes, pk)
156            .await?;
157    } else {
158        relay.submit_protobuf(&prepared.protobuf_bytes).await?;
159    }
160
161    Ok(prepared.fact_id)
162}
163
164/// Store multiple facts in a single on-chain transaction (batched UserOp).
165///
166/// Gas savings: ~64% vs individual submissions for batch of 5.
167/// Max batch size: 15 (matches extraction cap).
168pub async fn store_fact_batch(
169    facts: &[(&str, &str)], // (content, source) pairs
170    keys: &crypto::DerivedKeys,
171    lsh_hasher: &LshHasher,
172    embedding_provider: &dyn EmbeddingProvider,
173    relay: &RelayClient,
174    private_key: &[u8; 32],
175) -> Result<Vec<String>> {
176    let mut prepared_facts = Vec::with_capacity(facts.len());
177
178    for (content, source) in facts {
179        // Generate embedding (I/O)
180        let embedding = embedding_provider.embed(content).await?;
181
182        // Prepare fact via core (pure computation)
183        let prepared = core_store::prepare_fact_with_decay_score(
184            content,
185            &keys.encryption_key,
186            &keys.dedup_key,
187            lsh_hasher,
188            &embedding,
189            1.0,
190            source,
191            relay.wallet_address(),
192            "zeroclaw",
193        )
194        .map_err(|e| crate::Error::Crypto(e.to_string()))?;
195
196        prepared_facts.push(prepared);
197    }
198
199    // Collect protobuf payloads for batch submission
200    let protobuf_payloads: Vec<Vec<u8>> = prepared_facts
201        .iter()
202        .map(|p| p.protobuf_bytes.clone())
203        .collect();
204    let fact_ids: Vec<String> = prepared_facts.iter().map(|p| p.fact_id.clone()).collect();
205
206    // Submit all as one batched UserOp
207    relay
208        .submit_fact_batch_native(&protobuf_payloads, private_key)
209        .await?;
210
211    Ok(fact_ids)
212}
213
214/// Store a tombstone on-chain (soft-delete a fact).
215///
216/// Legacy (v3 outer protobuf). For Memory Taxonomy v1 vaults, prefer
217/// `store_tombstone_v1()` so the tombstone protobuf carries `version = 4`.
218pub async fn store_tombstone(
219    fact_id: &str,
220    relay: &RelayClient,
221    private_key: Option<&[u8; 32]>,
222) -> Result<()> {
223    let protobuf = core_store::prepare_tombstone(fact_id, relay.wallet_address());
224
225    if let Some(pk) = private_key {
226        relay.submit_fact_native(&protobuf, pk).await?;
227    } else {
228        relay.submit_protobuf(&protobuf).await?;
229    }
230    Ok(())
231}
232
233/// Store a Memory Taxonomy v1 tombstone on-chain.
234///
235/// Emits `version = 4` on the outer protobuf so the subgraph indexes this
236/// tombstone under the v1 taxonomy. Semantically identical to
237/// `store_tombstone()`.
238pub async fn store_tombstone_v1(
239    fact_id: &str,
240    relay: &RelayClient,
241    private_key: Option<&[u8; 32]>,
242) -> Result<()> {
243    let protobuf = core_store::prepare_tombstone_v1(fact_id, relay.wallet_address());
244
245    if let Some(pk) = private_key {
246        relay.submit_fact_native(&protobuf, pk).await?;
247    } else {
248        relay.submit_protobuf(&protobuf).await?;
249    }
250    Ok(())
251}
252
253// ---------------------------------------------------------------------------
254// Memory Taxonomy v1 store path
255// ---------------------------------------------------------------------------
256
257/// Input for building a v1 memory claim from ZeroClaw's high-level API.
258///
259/// The ZeroClaw Memory trait deals in `(key, content, category, session_id)`.
260/// `V1StoreInput` is the adapter shape that maps those plus explicit v1
261/// provenance (`source`, `scope`, `volatility`) onto the canonical
262/// `MemoryClaimV1` the core write path expects.
263#[derive(Debug, Clone)]
264pub struct V1StoreInput {
265    /// Plaintext body of the claim (5-512 UTF-8 chars).
266    pub text: String,
267    /// v1 memory type (claim | preference | directive | commitment |
268    /// episode | summary).
269    pub memory_type: MemoryTypeV1,
270    /// v1 provenance source.
271    pub source: MemorySource,
272    /// Importance on the 1-10 scale. Normalized to 0.0-1.0 on-chain.
273    pub importance: u8,
274    /// Life-domain scope. Defaults to `Unspecified`.
275    pub scope: MemoryScope,
276    /// Stability signal. Defaults to `Updatable`.
277    pub volatility: MemoryVolatility,
278    /// Decision-with-reasoning clause (only meaningful for `type: claim`).
279    pub reasoning: Option<String>,
280}
281
282impl V1StoreInput {
283    /// Convenience constructor for a plain claim with default scope + volatility.
284    pub fn new_claim(text: impl Into<String>, importance: u8) -> Self {
285        Self {
286            text: text.into(),
287            memory_type: MemoryTypeV1::Claim,
288            source: MemorySource::UserInferred,
289            importance,
290            scope: MemoryScope::Unspecified,
291            volatility: MemoryVolatility::Updatable,
292            reasoning: None,
293        }
294    }
295}
296
297/// Build a canonical `MemoryClaimV1` from a `V1StoreInput`.
298///
299/// Populates `id` (UUIDv7) and `created_at` (RFC 3339 UTC) and threads the
300/// rest through verbatim. The resulting claim is the JSON envelope that
301/// `prepare_fact_v1()` encrypts into the outer v4 protobuf.
302pub fn build_memory_claim_v1(input: &V1StoreInput) -> MemoryClaimV1 {
303    MemoryClaimV1 {
304        id: uuid::Uuid::now_v7().to_string(),
305        text: input.text.clone(),
306        memory_type: input.memory_type,
307        source: input.source,
308        created_at: chrono::Utc::now()
309            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
310        schema_version: MEMORY_CLAIM_V1_SCHEMA_VERSION.to_string(),
311        scope: input.scope,
312        volatility: input.volatility,
313        entities: Vec::new(),
314        reasoning: input.reasoning.clone(),
315        expires_at: None,
316        importance: Some(input.importance),
317        confidence: None,
318        superseded_by: None,
319        // v1.1 additive field: pin_status is user-controlled via totalreclaw_pin.
320        // Extraction-path claims start unpinned (field absent).
321        pin_status: None,
322    }
323}
324
325/// Store a Memory Taxonomy v1 claim on-chain.
326///
327/// Full v1 pipeline:
328///  1. Build canonical `MemoryClaimV1`
329///  2. Serialize to JSON envelope (the inner blob)
330///  3. Content fingerprint exact-dedup check (tombstone v4 if match)
331///  4. Generate embedding
332///  5. Best-match near-duplicate dedup (tombstone v4 if match, cosine ≥ 0.85)
333///  6. `core::prepare_fact_v1()` — encrypt envelope, build blind indices,
334///     encrypt embedding, emit v4 protobuf
335///  7. Submit via native UserOp (or legacy if no private key)
336///
337/// Returns the fact_id of the stored claim.
338pub async fn store_fact_v1(
339    input: &V1StoreInput,
340    keys: &crypto::DerivedKeys,
341    lsh_hasher: &LshHasher,
342    embedding_provider: &dyn EmbeddingProvider,
343    relay: &RelayClient,
344    private_key: Option<&[u8; 32]>,
345) -> Result<String> {
346    // 1. Build canonical v1 claim
347    let claim = build_memory_claim_v1(input);
348
349    // 2. Serialize envelope
350    let envelope_json = serde_json::to_string(&claim)
351        .map_err(|e| crate::Error::Crypto(format!("v1 envelope serialize: {e}")))?;
352
353    // 3. Exact-dedup via content fingerprint
354    let content_fp = fingerprint::generate_content_fingerprint(&claim.text, &keys.dedup_key);
355    if let Ok(Some(dup)) =
356        search::search_by_fingerprint(relay, relay.wallet_address(), &content_fp).await
357    {
358        // v1 vaults emit v4 tombstones
359        let _ = store_tombstone_v1(&dup.id, relay, private_key).await;
360    }
361
362    // 4. Generate embedding
363    let embedding = embedding_provider.embed(&claim.text).await?;
364
365    // 5. Best-match near-duplicate supersede
366    if let Some(dup) = find_best_duplicate(&claim.text, &embedding, keys, relay).await {
367        let _ = store_tombstone_v1(&dup.fact_id, relay, private_key).await;
368    }
369
370    // 6. Prepare v1 fact (encrypt envelope + v4 protobuf)
371    //    Source tag for on-chain analytics uses the v1 provenance token
372    //    (e.g. `zeroclaw_v1_user-inferred`).
373    let source_tag = format!("zeroclaw_v1_{}", v1_source_to_str(input.source));
374    let prepared = core_store::prepare_fact_v1(
375        &envelope_json,
376        &claim.text,
377        &keys.encryption_key,
378        &keys.dedup_key,
379        lsh_hasher,
380        &embedding,
381        input.importance as f64,
382        &source_tag,
383        relay.wallet_address(),
384        "zeroclaw",
385    )
386    .map_err(|e| crate::Error::Crypto(e.to_string()))?;
387
388    // 7. Submit
389    if let Some(pk) = private_key {
390        relay.submit_fact_native(&prepared.protobuf_bytes, pk).await?;
391    } else {
392        relay.submit_protobuf(&prepared.protobuf_bytes).await?;
393    }
394
395    Ok(prepared.fact_id)
396}
397
398/// Render a `MemorySource` enum value to its kebab-case wire token.
399fn v1_source_to_str(src: MemorySource) -> &'static str {
400    match src {
401        MemorySource::User => "user",
402        MemorySource::UserInferred => "user-inferred",
403        MemorySource::Assistant => "assistant",
404        MemorySource::External => "external",
405        MemorySource::Derived => "derived",
406    }
407}
408
409// ---------------------------------------------------------------------------
410// Store-time near-duplicate detection
411// ---------------------------------------------------------------------------
412
413/// Find the best near-duplicate among existing facts using core's
414/// `find_best_near_duplicate` (returns highest-similarity match, not first).
415///
416/// Fetches up to `STORE_DEDUP_MAX_CANDIDATES` existing facts via blind index
417/// search, decrypts their embeddings, and delegates to the core consolidation
418/// module.
419///
420/// Returns `Some(DupMatch)` if a match above `STORE_DEDUP_COSINE_THRESHOLD`
421/// is found, `None` otherwise.
422async fn find_best_duplicate(
423    content: &str,
424    new_embedding: &[f32],
425    keys: &crypto::DerivedKeys,
426    relay: &RelayClient,
427) -> Option<consolidation::DupMatch> {
428    // Generate word trapdoors from the content being stored
429    let trapdoors = blind::generate_blind_indices(content);
430    if trapdoors.is_empty() {
431        return None;
432    }
433
434    // Fetch existing candidates (up to core's STORE_DEDUP_MAX_CANDIDATES)
435    let candidates = search::search_candidates(
436        relay,
437        relay.wallet_address(),
438        &trapdoors,
439        consolidation::STORE_DEDUP_MAX_CANDIDATES,
440    )
441    .await
442    .ok()?;
443
444    // Decrypt embeddings into (id, embedding) pairs for the core function
445    let mut existing: Vec<(String, Vec<f32>)> = Vec::with_capacity(candidates.len());
446    for fact in &candidates {
447        let enc_emb = match &fact.encrypted_embedding {
448            Some(e) => e,
449            None => continue,
450        };
451        let b64 = match crypto::decrypt(enc_emb, &keys.encryption_key) {
452            Ok(b) => b,
453            Err(_) => continue,
454        };
455        let bytes = match base64::engine::general_purpose::STANDARD.decode(&b64) {
456            Ok(b) => b,
457            Err(_) => continue,
458        };
459        let emb: Vec<f32> = bytes
460            .chunks_exact(4)
461            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
462            .collect();
463        existing.push((fact.id.clone(), emb));
464    }
465
466    consolidation::find_best_near_duplicate(
467        new_embedding,
468        &existing,
469        consolidation::STORE_DEDUP_COSINE_THRESHOLD,
470    )
471}
472
473// ---------------------------------------------------------------------------
474// Phase 2 KG: Contradiction-aware store
475// ---------------------------------------------------------------------------
476
477/// Result of a contradiction-checked store operation.
478#[derive(Debug)]
479pub struct ContradictionStoreResult {
480    /// The fact ID that was stored (or would be stored).
481    pub fact_id: String,
482    /// Resolution actions taken (supersede, skip, tie). Empty if no contradictions.
483    pub actions: Vec<ResolutionAction>,
484    /// Decision log entries generated for audit trail. Empty if no contradictions.
485    pub decision_log_entries: Vec<decision_log::DecisionLogEntry>,
486}
487
488/// Store a claim with full Phase 2 contradiction detection.
489///
490/// This is the KG-aware store path. It:
491/// 1. Runs content fingerprint exact-dedup (same as basic store)
492/// 2. Generates embedding
493/// 3. Runs `resolve_with_candidates` from core against pre-fetched candidates
494/// 4. For `SupersedeExisting` actions: tombstones the existing claim
495/// 5. For `SkipNew` actions: skips storing entirely
496/// 6. For `TieLeaveBoth` or no contradictions: stores normally
497/// 7. Returns decision log entries for the caller to persist
498///
499/// All I/O (candidate fetching, decryption) is done here in the adapter layer.
500/// Pure contradiction logic is delegated to `totalreclaw_core::contradiction`.
501pub async fn store_claim_with_contradiction_check(
502    claim: &Claim,
503    claim_id: &str,
504    source: &str,
505    importance: f64,
506    keys: &crypto::DerivedKeys,
507    lsh_hasher: &LshHasher,
508    embedding_provider: &dyn EmbeddingProvider,
509    relay: &RelayClient,
510    private_key: Option<&[u8; 32]>,
511    weights: &contradiction::ResolutionWeights,
512    now_unix_seconds: i64,
513) -> Result<ContradictionStoreResult> {
514    let content = &claim.text;
515
516    // 1. Content fingerprint exact-dedup
517    let content_fp = fingerprint::generate_content_fingerprint(content, &keys.dedup_key);
518    if let Ok(Some(dup)) =
519        search::search_by_fingerprint(relay, relay.wallet_address(), &content_fp).await
520    {
521        let _ = store_tombstone(&dup.id, relay, private_key).await;
522    }
523
524    // 2. Generate embedding
525    let embedding = embedding_provider.embed(content).await?;
526
527    // 3. Fetch candidates for contradiction detection (by entity blind indices)
528    let candidates = fetch_contradiction_candidates(
529        claim,
530        &embedding,
531        keys,
532        relay,
533    )
534    .await;
535
536    // 4. Run core contradiction resolution
537    let actions = contradiction::resolve_with_candidates(
538        claim,
539        claim_id,
540        &embedding,
541        &candidates,
542        weights,
543        contradiction::DEFAULT_LOWER_THRESHOLD,
544        contradiction::DEFAULT_UPPER_THRESHOLD,
545        now_unix_seconds,
546        totalreclaw_core::claims::TIE_ZONE_SCORE_TOLERANCE,
547    );
548
549    // 5. Build decision log entries
550    let existing_claims_json: std::collections::HashMap<String, String> = candidates
551        .iter()
552        .filter_map(|(c, id, _)| {
553            serde_json::to_string(c).ok().map(|json| (id.clone(), json))
554        })
555        .collect();
556    let new_claim_json = serde_json::to_string(claim).unwrap_or_default();
557    let decision_log_entries = contradiction::build_decision_log_entries(
558        &actions,
559        &new_claim_json,
560        &existing_claims_json,
561        "active",
562        now_unix_seconds,
563    );
564
565    // 6. Process actions
566    let mut should_store = true;
567    for action in &actions {
568        match action {
569            ResolutionAction::SupersedeExisting { existing_id, .. } => {
570                // Tombstone the losing existing claim
571                let _ = store_tombstone(existing_id, relay, private_key).await;
572            }
573            ResolutionAction::SkipNew { .. } => {
574                // Existing claim wins or is pinned — do not store the new claim
575                should_store = false;
576                break;
577            }
578            ResolutionAction::TieLeaveBoth { .. } | ResolutionAction::NoContradiction => {
579                // Keep both — store normally
580            }
581        }
582    }
583
584    if !should_store {
585        return Ok(ContradictionStoreResult {
586            fact_id: claim_id.to_string(),
587            actions,
588            decision_log_entries,
589        });
590    }
591
592    // 7. Store the new claim (standard pipeline)
593    let prepared = core_store::prepare_fact(
594        content,
595        &keys.encryption_key,
596        &keys.dedup_key,
597        lsh_hasher,
598        &embedding,
599        importance,
600        source,
601        relay.wallet_address(),
602        "zeroclaw",
603    )
604    .map_err(|e| crate::Error::Crypto(e.to_string()))?;
605
606    if let Some(pk) = private_key {
607        relay
608            .submit_fact_native(&prepared.protobuf_bytes, pk)
609            .await?;
610    } else {
611        relay.submit_protobuf(&prepared.protobuf_bytes).await?;
612    }
613
614    Ok(ContradictionStoreResult {
615        fact_id: prepared.fact_id,
616        actions,
617        decision_log_entries,
618    })
619}
620
621/// Fetch and decrypt candidate claims for contradiction detection.
622///
623/// Uses entity names from the new claim to generate blind index trapdoors,
624/// then decrypts the returned facts into `(Claim, id, embedding)` tuples
625/// that `resolve_with_candidates` expects.
626async fn fetch_contradiction_candidates(
627    new_claim: &Claim,
628    _new_embedding: &[f32],
629    keys: &crypto::DerivedKeys,
630    relay: &RelayClient,
631) -> Vec<(Claim, String, Vec<f32>)> {
632    if new_claim.entities.is_empty() {
633        return Vec::new();
634    }
635
636    // Generate trapdoors from entity names
637    let mut trapdoors = Vec::new();
638    for entity in &new_claim.entities {
639        trapdoors.extend(blind::generate_blind_indices(&entity.name));
640    }
641    if trapdoors.is_empty() {
642        return Vec::new();
643    }
644
645    // Fetch candidates from subgraph
646    let facts = match search::search_candidates(
647        relay,
648        relay.wallet_address(),
649        &trapdoors,
650        decision_log::CONTRADICTION_CANDIDATE_CAP,
651    )
652    .await
653    {
654        Ok(f) => f,
655        Err(_) => return Vec::new(),
656    };
657
658    // Decrypt and parse each candidate into (Claim, id, embedding)
659    let mut candidates = Vec::new();
660    for fact in &facts {
661        // Decrypt content blob
662        let blob_b64 = match search::hex_blob_to_base64(&fact.encrypted_blob) {
663            Some(b) => b,
664            None => continue,
665        };
666        let decrypted = match crypto::decrypt(&blob_b64, &keys.encryption_key) {
667            Ok(t) => t,
668            Err(_) => continue,
669        };
670
671        // Try to parse as a canonical Claim (KG facts store claims as the envelope)
672        // Fall back: try parsing the "t" field from the standard envelope as a Claim
673        let claim: Claim = if let Ok(c) = serde_json::from_str(&decrypted) {
674            c
675        } else if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&decrypted) {
676            let text = obj.get("t").and_then(|v| v.as_str()).unwrap_or(&decrypted);
677            match serde_json::from_str(text) {
678                Ok(c) => c,
679                Err(_) => continue, // Not a Claim — skip for contradiction detection
680            }
681        } else {
682            continue;
683        };
684
685        // Decrypt embedding
686        let emb = fact
687            .encrypted_embedding
688            .as_deref()
689            .and_then(|e| crypto::decrypt(e, &keys.encryption_key).ok())
690            .and_then(|b64| {
691                base64::engine::general_purpose::STANDARD
692                    .decode(&b64)
693                    .ok()
694            })
695            .map(|bytes| {
696                bytes
697                    .chunks_exact(4)
698                    .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
699                    .collect::<Vec<f32>>()
700            })
701            .unwrap_or_default();
702
703        candidates.push((claim, fact.id.clone(), emb));
704    }
705
706    candidates
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[test]
714    fn test_store_dedup_threshold_matches_core() {
715        // Verify the core constant matches spec
716        assert!(
717            (consolidation::STORE_DEDUP_COSINE_THRESHOLD - 0.85).abs() < 1e-10
718        );
719    }
720
721    #[test]
722    fn test_store_dedup_fetch_limit_matches_core() {
723        // Verify the core constant matches spec
724        assert_eq!(consolidation::STORE_DEDUP_MAX_CANDIDATES, 50);
725    }
726
727    #[test]
728    fn test_find_best_near_duplicate_selects_highest() {
729        // Verify best-match behaviour: given two candidates above threshold,
730        // the one with higher similarity wins.
731        let new_emb: Vec<f32> = vec![1.0, 0.0, 0.0];
732        let existing = vec![
733            ("id_a".to_string(), vec![0.9, 0.1, 0.0]),  // lower similarity
734            ("id_b".to_string(), vec![0.99, 0.01, 0.0]), // higher similarity
735        ];
736
737        let result =
738            consolidation::find_best_near_duplicate(&new_emb, &existing, 0.5);
739        assert!(result.is_some());
740        let dup = result.unwrap();
741        assert_eq!(dup.fact_id, "id_b");
742        assert!(dup.similarity > 0.99);
743    }
744
745    #[test]
746    fn test_find_best_near_duplicate_none_below_threshold() {
747        let new_emb: Vec<f32> = vec![1.0, 0.0, 0.0];
748        let existing = vec![
749            ("id_a".to_string(), vec![0.0, 1.0, 0.0]), // orthogonal
750        ];
751
752        let result = consolidation::find_best_near_duplicate(
753            &new_emb,
754            &existing,
755            consolidation::STORE_DEDUP_COSINE_THRESHOLD,
756        );
757        assert!(result.is_none());
758    }
759
760    #[test]
761    fn test_importance_normalization() {
762        // Spec: decayScore = importance / 10
763        // Input 8 on 1-10 scale -> 0.8
764        let importance: f64 = 8.0;
765        let decay_score = (importance / 10.0).clamp(0.0, 1.0);
766        assert!((decay_score - 0.8).abs() < 1e-10);
767
768        // Edge cases
769        assert!((0.0_f64 / 10.0).clamp(0.0, 1.0) == 0.0);
770        assert!((10.0_f64 / 10.0).clamp(0.0, 1.0) == 1.0);
771        assert!((15.0_f64 / 10.0).clamp(0.0, 1.0) == 1.0); // Clamped to 1.0
772    }
773
774    // -----------------------------------------------------------------------
775    // Phase 2 KG: Core types accessible via this crate
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn test_core_claim_types_accessible() {
780        use totalreclaw_core::claims::{
781            Claim, ClaimCategory, ClaimStatus, EntityRef, EntityType,
782        };
783
784        let claim = Claim {
785            text: "Pedro uses ZeroClaw".to_string(),
786            category: ClaimCategory::Fact,
787            confidence: 0.9,
788            importance: 8,
789            corroboration_count: 1,
790            source_agent: "zeroclaw".to_string(),
791            source_conversation: None,
792            extracted_at: Some("2026-04-16T12:00:00Z".to_string()),
793            entities: vec![EntityRef {
794                name: "Pedro".to_string(),
795                entity_type: EntityType::Person,
796                role: Some("user".to_string()),
797            }],
798            supersedes: None,
799            superseded_by: None,
800            valid_from: None,
801            status: ClaimStatus::Active,
802        };
803        assert_eq!(claim.category, ClaimCategory::Fact);
804        assert!(!totalreclaw_core::claims::is_pinned_claim(&claim));
805    }
806
807    #[test]
808    fn test_pinned_claim_detection() {
809        use totalreclaw_core::claims::{Claim, ClaimCategory, ClaimStatus};
810
811        let mut claim = Claim {
812            text: "pinned fact".to_string(),
813            category: ClaimCategory::Fact,
814            confidence: 1.0,
815            importance: 10,
816            corroboration_count: 1,
817            source_agent: "totalreclaw_remember".to_string(),
818            source_conversation: None,
819            extracted_at: None,
820            entities: vec![],
821            supersedes: None,
822            superseded_by: None,
823            valid_from: None,
824            status: ClaimStatus::Active,
825        };
826        assert!(!totalreclaw_core::claims::is_pinned_claim(&claim));
827
828        claim.status = ClaimStatus::Pinned;
829        assert!(totalreclaw_core::claims::is_pinned_claim(&claim));
830    }
831
832    #[test]
833    fn test_resolve_with_candidates_no_entities() {
834        use totalreclaw_core::claims::{Claim, ClaimCategory, ClaimStatus};
835
836        let claim = Claim {
837            text: "no entities here".to_string(),
838            category: ClaimCategory::Fact,
839            confidence: 0.9,
840            importance: 7,
841            corroboration_count: 1,
842            source_agent: "zeroclaw".to_string(),
843            source_conversation: None,
844            extracted_at: None,
845            entities: vec![], // no entities => no contradictions possible
846            supersedes: None,
847            superseded_by: None,
848            valid_from: None,
849            status: ClaimStatus::Active,
850        };
851
852        let emb = vec![1.0_f32; 3];
853        let weights = contradiction::default_weights();
854        let actions = contradiction::resolve_with_candidates(
855            &claim,
856            "new_id",
857            &emb,
858            &[], // no candidates
859            &weights,
860            contradiction::DEFAULT_LOWER_THRESHOLD,
861            contradiction::DEFAULT_UPPER_THRESHOLD,
862            1_776_384_000,
863            totalreclaw_core::claims::TIE_ZONE_SCORE_TOLERANCE,
864        );
865        assert!(actions.is_empty());
866    }
867
868    #[test]
869    fn test_decision_log_entry_round_trip() {
870        let entry = decision_log::DecisionLogEntry {
871            ts: 1_776_384_000,
872            entity_id: "ent123".to_string(),
873            new_claim_id: "0xnew".to_string(),
874            existing_claim_id: "0xold".to_string(),
875            similarity: 0.72,
876            action: "supersede_existing".to_string(),
877            reason: Some("new_wins".to_string()),
878            winner_score: Some(0.73),
879            loser_score: Some(0.40),
880            winner_components: None,
881            loser_components: None,
882            loser_claim_json: None,
883            mode: "active".to_string(),
884        };
885        let json = serde_json::to_string(&entry).unwrap();
886        let back: decision_log::DecisionLogEntry = serde_json::from_str(&json).unwrap();
887        assert_eq!(entry, back);
888    }
889
890    #[test]
891    fn test_contradiction_candidate_cap() {
892        assert_eq!(decision_log::CONTRADICTION_CANDIDATE_CAP, 20);
893    }
894
895    #[test]
896    fn test_default_weights() {
897        let w = contradiction::default_weights();
898        let sum = w.confidence + w.corroboration + w.recency + w.validation;
899        assert!((sum - 1.0).abs() < 1e-10, "weights should sum to 1.0");
900    }
901
902    #[test]
903    fn test_tie_zone_tolerance() {
904        assert!(
905            (totalreclaw_core::claims::TIE_ZONE_SCORE_TOLERANCE - 0.01).abs() < 1e-10,
906            "tie zone tolerance should be 0.01"
907        );
908    }
909}