Skip to main content

nexus_memory_agent/
reflect.rs

1//! Reflection service - deterministic reinforcement and contradiction detection.
2//!
3//! Implements a typed, deterministic reflection pipeline that scans perspective-aligned
4//! memories for reinforcement patterns and simple contradiction cases. Outputs are
5//! persisted as derived/contradiction memories with evidence lineage.
6//!
7//! Explicit seam: the `ReflectService` struct is designed to accept an optional LLM
8//! client in future phases for deeper semantic reflection, but the current slice
9//! operates entirely through deterministic content analysis.
10
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13use std::time::Instant;
14
15use chrono::Utc;
16use nexus_core::config::{AgentConfig, CognitionConfig};
17use nexus_core::traits::EmbeddingService;
18use nexus_core::{
19    CognitiveLevel, CognitiveMetadata, Memory, MemoryCategory, MemoryLaneCognitiveType,
20    MemoryLanePriorityType, MemoryLaneType, PerspectiveKey,
21};
22use nexus_storage::repository::{
23    ListMemoryFilters, MemoryRepository, StoreMemoryParams, StoreMemoryWithLineageParams,
24};
25use tracing::{debug, info};
26
27use crate::error::AgentError;
28use crate::util::{flush_metric_samples, maybe_embed, stage_metric_sample, CognitionSnapshot};
29
30// ---------------------------------------------------------------------------
31// Constants
32// ---------------------------------------------------------------------------
33
34const RAW_ACTIVITY_LABEL: &str = "raw-activity";
35const LOW_SIGNAL_LABEL: &str = "low-signal";
36const REFLECT_GENERATED_BY: &str = "reflect_service";
37const REINFORCE_EVIDENCE_ROLE: &str = "reinforces";
38const CONTRADICT_EVIDENCE_ROLE: &str = "contradicts";
39const INSIGHT_EVIDENCE_ROLE: &str = "insight_support";
40const MAX_CANDIDATES: i64 = 100;
41const MIN_INSIGHT_COMPONENT_SIZE: usize = 3;
42const MAX_INSIGHT_CONTENT_CHARS: usize = 8000;
43
44/// Word-level Jaccard similarity threshold for reinforcement detection.
45const REINFORCE_SIMILARITY_THRESHOLD: f32 = 0.80;
46const INSIGHT_SIMILARITY_THRESHOLD: f32 = 0.55;
47
48/// Minimum topic overlap for contradiction candidate consideration.
49const CONTRADICTION_MIN_TOPIC_OVERLAP: f32 = 0.30;
50
51/// Negation words that signal contradiction when paired with an affirmative claim.
52const NEGATION_WORDS: &[&str] = &[
53    "not",
54    "no",
55    "never",
56    "don't",
57    "doesn't",
58    "can't",
59    "cannot",
60    "won't",
61    "isn't",
62    "aren't",
63    "wasn't",
64    "weren't",
65    "shouldn't",
66    "wouldn't",
67    "couldn't",
68];
69
70/// Stop words excluded from topic comparison.
71const STOP_WORDS: &[&str] = &[
72    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
73    "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "to",
74    "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during",
75    "before", "after", "above", "below", "between", "and", "but", "or", "nor", "if", "then",
76    "that", "this", "these", "those", "it", "its", "we", "our", "they", "their", "he", "she",
77    "his", "her", "my", "your",
78];
79
80// ---------------------------------------------------------------------------
81// Public types
82// ---------------------------------------------------------------------------
83
84/// What kind of reflection was detected between memories.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ReflectionCase {
87    /// Memories reinforce the same observation (high content similarity, same level).
88    Reinforcement,
89    /// Memories assert contradictory claims about the same topic.
90    Contradiction,
91}
92
93/// Output of a single reflection comparison between two memories.
94#[derive(Debug, Clone)]
95pub struct ReflectionOutput {
96    pub case: ReflectionCase,
97    pub left_id: i64,
98    pub right_id: i64,
99    pub similarity: f32,
100}
101
102/// Summary of a full reflection cycle.
103#[derive(Debug, Clone, Default)]
104pub struct ReflectionResult {
105    pub memories_scanned: usize,
106    pub pairs_compared: usize,
107    pub reinforcements: usize,
108    pub insights_created: usize,
109    pub contradictions_created: usize,
110    pub insight_ids: Vec<i64>,
111    pub contradiction_ids: Vec<i64>,
112}
113
114// ---------------------------------------------------------------------------
115// Service
116// ---------------------------------------------------------------------------
117
118/// Deterministic reflection engine for reinforcement and contradiction detection.
119///
120/// Operates over perspective-aligned Explicit and Derived memories within a single
121/// namespace. Each cycle is bounded by `MAX_CANDIDATES` and idempotent — pairs that
122/// already have evidence links are skipped.
123pub struct ReflectService {
124    _config: AgentConfig,
125    cognition: CognitionConfig,
126    embeddings: Option<Arc<dyn EmbeddingService>>,
127}
128
129impl ReflectService {
130    pub fn new(
131        config: AgentConfig,
132        cognition: CognitionConfig,
133        embeddings: Option<Arc<dyn EmbeddingService>>,
134    ) -> Self {
135        Self {
136            _config: config,
137            cognition,
138            embeddings,
139        }
140    }
141
142    /// Run a single bounded reflection cycle over the namespace's memories.
143    ///
144    /// Scans up to `MAX_CANDIDATES` non-raw memories, compares all eligible pairs,
145    /// and persists reinforcement or contradiction outputs. Returns a summary of
146    /// what was found and created.
147    pub async fn reflect_cycle(
148        &self,
149        namespace_id: i64,
150        repo: &MemoryRepository,
151    ) -> Result<ReflectionResult, AgentError> {
152        let started = Instant::now();
153        let groups = gather_candidates(namespace_id, repo).await?;
154        let scanned: usize = groups.values().map(Vec::len).sum();
155        if scanned < 2 {
156            debug!(namespace_id, "Not enough candidates for reflection");
157            return Ok(ReflectionResult::default());
158        }
159
160        let mut result = ReflectionResult {
161            memories_scanned: scanned,
162            ..Default::default()
163        };
164
165        for (perspective, candidates) in groups {
166            let group_result = run_reflection_group(
167                candidates,
168                &perspective,
169                repo,
170                self.embeddings.as_deref(),
171                &self.cognition,
172            )
173            .await?;
174            result.pairs_compared += group_result.pairs_compared;
175            result.reinforcements += group_result.reinforcements;
176            result.insights_created += group_result.insights_created;
177            result.contradictions_created += group_result.contradictions_created;
178            result.insight_ids.extend(group_result.insight_ids);
179            result
180                .contradiction_ids
181                .extend(group_result.contradiction_ids);
182        }
183
184        info!(
185            namespace_id,
186            scanned,
187            pairs = result.pairs_compared,
188            reinforcements = result.reinforcements,
189            contradictions = result.contradictions_created,
190            "Reflection cycle complete"
191        );
192        flush_metric_samples(
193            repo,
194            &[stage_metric_sample(
195                namespace_id,
196                "cognition.reflect.total_ms",
197                started.elapsed().as_secs_f64() * 1000.0,
198                "reflect_total",
199            )],
200        )
201        .await;
202
203        Ok(result)
204    }
205
206    pub async fn reflect_perspective_cycle(
207        &self,
208        namespace_id: i64,
209        perspective: &PerspectiveKey,
210        repo: &MemoryRepository,
211    ) -> Result<ReflectionResult, AgentError> {
212        let groups = gather_candidates(namespace_id, repo).await?;
213        let candidates = groups.get(perspective).cloned().unwrap_or_default();
214        run_reflection_group(
215            candidates,
216            perspective,
217            repo,
218            self.embeddings.as_deref(),
219            &self.cognition,
220        )
221        .await
222    }
223
224    /// Compare two memories and return the reflection case, if any.
225    ///
226    /// This is the pure deterministic comparison logic, exposed for testing and
227    /// future LLM augmentation hooks.
228    pub fn compare_pair(left: &Memory, right: &Memory) -> Option<ReflectionCase> {
229        compare_pair(left, right)
230    }
231}
232
233async fn run_reflection_group(
234    candidates: Vec<Memory>,
235    perspective: &PerspectiveKey,
236    repo: &MemoryRepository,
237    embeddings: Option<&dyn EmbeddingService>,
238    cognition: &CognitionConfig,
239) -> Result<ReflectionResult, AgentError> {
240    let scanned = candidates.len();
241    if scanned < 2 {
242        return Ok(ReflectionResult {
243            memories_scanned: scanned,
244            ..Default::default()
245        });
246    }
247
248    let existing_links = load_pair_evidence(repo, &candidates).await?;
249    let mut result = ReflectionResult {
250        memories_scanned: scanned,
251        ..Default::default()
252    };
253    let mut reinforcement_pairs = Vec::new();
254
255    for i in 0..candidates.len() {
256        for j in (i + 1)..candidates.len() {
257            let left = &candidates[i];
258            let right = &candidates[j];
259            result.pairs_compared += 1;
260
261            let pair_key = ordered_pair_key(left.id, right.id);
262            if existing_links.contains(&pair_key) {
263                continue;
264            }
265
266            match compare_pair(left, right) {
267                Some(ReflectionCase::Reinforcement) => {
268                    handle_reinforcement(left, right, perspective, repo, embeddings).await?;
269                    reinforcement_pairs.push((left.id, right.id));
270                    result.reinforcements += 1;
271                }
272                Some(ReflectionCase::Contradiction) => {
273                    let contradiction_id =
274                        handle_contradiction(left, right, perspective, repo, embeddings, cognition)
275                            .await?;
276                    result.contradiction_ids.push(contradiction_id);
277                    result.contradictions_created += 1;
278                }
279                None => {}
280            }
281        }
282    }
283
284    let insight_ids = synthesize_reinforcement_insights(
285        &candidates,
286        &reinforcement_pairs,
287        perspective,
288        repo,
289        embeddings,
290    )
291    .await?;
292    result.insights_created = insight_ids.len();
293    result.insight_ids = insight_ids;
294
295    Ok(result)
296}
297
298// ---------------------------------------------------------------------------
299// Candidate gathering
300// ---------------------------------------------------------------------------
301
302async fn gather_candidates(
303    namespace_id: i64,
304    repo: &MemoryRepository,
305) -> Result<HashMap<PerspectiveKey, Vec<Memory>>, AgentError> {
306    let all = repo
307        .list_filtered(
308            namespace_id,
309            ListMemoryFilters {
310                category: None,
311                since: None,
312                until: None,
313                content_like: None,
314                include_raw: false,
315                limit: MAX_CANDIDATES,
316                offset: 0,
317            },
318        )
319        .await
320        .map_err(|e| AgentError::Storage(e.to_string()))?;
321
322    // Only consider Explicit and Derived level memories for reflection.
323    // Skip system lifecycle markers (session start/end, runtime markers) and raw-activity noise.
324    let mut candidates: HashMap<PerspectiveKey, Vec<Memory>> = HashMap::new();
325    for memory in all {
326        let snapshot = CognitionSnapshot::from_memory(&memory);
327        if matches!(
328            snapshot.level,
329            CognitiveLevel::Explicit | CognitiveLevel::Derived
330        ) && !is_reflection_generated(&snapshot, &memory)
331        {
332            // Exclude pure system operational memories
333            if memory.metadata.get("session_lifecycle").is_some() {
334                continue;
335            }
336            if memory.metadata.get("runtime").is_some() {
337                continue;
338            }
339            // Exclude raw-activity noise (should already be filtered by include_raw=false,
340            // but double-check in case labels were preserved)
341            if memory
342                .labels
343                .iter()
344                .any(|l| l == RAW_ACTIVITY_LABEL || l == LOW_SIGNAL_LABEL)
345            {
346                continue;
347            }
348            if let Some(perspective) = snapshot.perspective {
349                candidates.entry(perspective).or_default().push(memory);
350            }
351        }
352    }
353
354    Ok(candidates)
355}
356
357fn is_reflection_generated(snapshot: &CognitionSnapshot, memory: &Memory) -> bool {
358    memory.labels.iter().any(|label| label == "reflection")
359        || snapshot
360            .generated_by
361            .as_deref()
362            .map(|generated_by| generated_by == REFLECT_GENERATED_BY)
363            .unwrap_or(false)
364}
365
366// ---------------------------------------------------------------------------
367// Idempotency: evidence-based dedup
368// ---------------------------------------------------------------------------
369
370type PairKey = (i64, i64);
371
372fn ordered_pair_key(a: i64, b: i64) -> PairKey {
373    if a <= b {
374        (a, b)
375    } else {
376        (b, a)
377    }
378}
379
380/// Load evidence-based pair tracking for idempotency.
381///
382/// Evidence rows created by `store_with_lineage` link a reflection memory (derived)
383/// to each source individually. To detect that a pair of sources already shares a
384/// reflection, we collect all reflection-memory → source links and group sources by
385/// their shared reflection memory. Any two sources linked to the same reflection
386/// memory are considered an already-processed pair.
387async fn load_pair_evidence(
388    repo: &MemoryRepository,
389    candidates: &[Memory],
390) -> Result<HashSet<PairKey>, AgentError> {
391    let candidate_ids: Vec<i64> = candidates.iter().map(|memory| memory.id).collect();
392    let lineage_by_memory = repo
393        .load_lineage_batch(&candidate_ids)
394        .await
395        .map_err(|e| AgentError::Storage(e.to_string()))?;
396
397    // Map: reflection_memory_id → Vec<source_memory_id>
398    let mut reflection_to_sources: HashMap<i64, Vec<i64>> = HashMap::new();
399
400    for mem in candidates {
401        if let Some(lineage) = lineage_by_memory.get(&mem.id) {
402            for entry in lineage {
403                let role = entry.evidence_role.to_lowercase();
404                if role == REINFORCE_EVIDENCE_ROLE || role == CONTRADICT_EVIDENCE_ROLE {
405                    let reflection_id = entry.derived_memory_id;
406                    let source_id = entry.source_memory_id;
407                    // The reflection memory is derived_memory_id; the source is source_memory_id.
408                    // But load_lineage returns rows where either column matches mem.id,
409                    // so we need to figure out which is the reflection and which is the source.
410                    // Convention: store_with_lineage puts the NEW memory as derived_memory_id
411                    // and the ORIGINAL as source_memory_id.
412                    // Since we're loading lineage for a candidate (original), the candidate
413                    // will appear as source_memory_id in the evidence row.
414                    reflection_to_sources
415                        .entry(reflection_id)
416                        .or_default()
417                        .push(source_id);
418                }
419            }
420        }
421    }
422
423    // Any two sources sharing the same reflection memory form a processed pair.
424    let mut seen = HashSet::new();
425    for sources in reflection_to_sources.values() {
426        for i in 0..sources.len() {
427            for j in (i + 1)..sources.len() {
428                seen.insert(ordered_pair_key(sources[i], sources[j]));
429            }
430        }
431    }
432    Ok(seen)
433}
434
435// ---------------------------------------------------------------------------
436// Deterministic comparison
437// ---------------------------------------------------------------------------
438
439fn compare_pair(left: &Memory, right: &Memory) -> Option<ReflectionCase> {
440    let similarity = word_jaccard(&left.content, &right.content);
441
442    // High similarity → reinforcement.
443    if similarity >= REINFORCE_SIMILARITY_THRESHOLD {
444        return Some(ReflectionCase::Reinforcement);
445    }
446
447    // Moderate topic overlap with negation pattern → contradiction.
448    if similarity >= CONTRADICTION_MIN_TOPIC_OVERLAP
449        && has_negation_contradiction(&left.content, &right.content)
450    {
451        return Some(ReflectionCase::Contradiction);
452    }
453
454    None
455}
456
457fn word_jaccard(a: &str, b: &str) -> f32 {
458    let set_a: HashSet<&str> = a.split_whitespace().collect();
459    let set_b: HashSet<&str> = b.split_whitespace().collect();
460
461    if set_a.is_empty() && set_b.is_empty() {
462        return 1.0;
463    }
464    if set_a.is_empty() || set_b.is_empty() {
465        return 0.0;
466    }
467
468    let intersection: usize = set_a.intersection(&set_b).count();
469    let union: usize = set_a.union(&set_b).count();
470
471    intersection as f32 / union as f32
472}
473
474fn has_negation_contradiction(a: &str, b: &str) -> bool {
475    let words_a: Vec<&str> = a.split_whitespace().collect();
476    let words_b: Vec<&str> = b.split_whitespace().collect();
477
478    has_negation_in_other(&words_a, &words_b) || has_negation_in_other(&words_b, &words_a)
479}
480
481/// Check if `base_words` contains a negated version of a claim present in `other_words`.
482///
483/// Looks for patterns where `other` has a word/token and `base` has that same
484/// word preceded by a negation word within a 2-word window.
485fn has_negation_in_other(base_words: &[&str], other_words: &[&str]) -> bool {
486    let negation_set: HashSet<&str> = NEGATION_WORDS.iter().copied().collect();
487    let other_set: HashSet<&str> = other_words.iter().copied().collect();
488
489    for (i, word) in base_words.iter().enumerate() {
490        if negation_set.contains(word) {
491            // Check the next 1-2 words for a content word also present in the other set.
492            for offset in 1..=2 {
493                if i + offset < base_words.len() {
494                    let target = base_words[i + offset];
495                    if !STOP_WORDS.contains(&target)
496                        && !negation_set.contains(target)
497                        && other_set.contains(target)
498                    {
499                        return true;
500                    }
501                }
502            }
503        }
504    }
505
506    false
507}
508
509// ---------------------------------------------------------------------------
510// Reinforcement handling
511// ---------------------------------------------------------------------------
512
513async fn handle_reinforcement(
514    left: &Memory,
515    right: &Memory,
516    perspective: &PerspectiveKey,
517    repo: &MemoryRepository,
518    embeddings: Option<&dyn EmbeddingService>,
519) -> Result<(), AgentError> {
520    let content = format!("Reinforced observation ({}x): {}", 2, left.content.trim());
521
522    let mut cognitive = CognitiveMetadata::new(
523        CognitiveLevel::Derived,
524        perspective.observer.clone(),
525        perspective.subject.clone(),
526        perspective.session_key.clone(),
527        REFLECT_GENERATED_BY,
528    );
529    cognitive.source_memory_ids = vec![left.id, right.id];
530    cognitive.confidence = Some(0.75);
531    cognitive.times_reinforced = 2;
532    cognitive.times_contradicted = 0;
533    cognitive.derived_at = Some(Utc::now());
534    cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
535
536    let metadata = cognitive.merge_into(&serde_json::json!({}));
537    let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
538
539    repo.store_with_lineage(StoreMemoryWithLineageParams {
540        store: StoreMemoryParams {
541            namespace_id: left.namespace_id,
542            content: &content,
543            category: &MemoryCategory::Facts,
544            memory_lane_type: Some(&MemoryLaneType::Cognitive(
545                MemoryLaneCognitiveType::Metamemory,
546            )),
547            labels: &[
548                "reflection".to_string(),
549                "reinforcement".to_string(),
550                "auto".to_string(),
551            ],
552            metadata: &metadata,
553            embedding: embedding.as_deref(),
554            embedding_model: embedding_model.as_deref(),
555        },
556        source_memory_ids: &[left.id, right.id],
557        evidence_role: REINFORCE_EVIDENCE_ROLE,
558    })
559    .await
560    .map_err(|e| AgentError::Storage(e.to_string()))?;
561    increment_cognitive_counter(repo, left.id, "times_reinforced").await?;
562    increment_cognitive_counter(repo, right.id, "times_reinforced").await?;
563
564    debug!(
565        left_id = left.id,
566        right_id = right.id,
567        "Created reinforcement record"
568    );
569
570    Ok(())
571}
572
573// ---------------------------------------------------------------------------
574// Contradiction handling
575// ---------------------------------------------------------------------------
576
577async fn handle_contradiction(
578    left: &Memory,
579    right: &Memory,
580    perspective: &PerspectiveKey,
581    repo: &MemoryRepository,
582    embeddings: Option<&dyn EmbeddingService>,
583    cognition: &CognitionConfig,
584) -> Result<i64, AgentError> {
585    let content = format!(
586        "Contradiction: \"{}\" vs \"{}\"",
587        truncate_content(left.content.trim(), 200),
588        truncate_content(right.content.trim(), 200),
589    );
590
591    let mut cognitive = CognitiveMetadata::new(
592        CognitiveLevel::Contradiction,
593        perspective.observer.clone(),
594        perspective.subject.clone(),
595        perspective.session_key.clone(),
596        REFLECT_GENERATED_BY,
597    );
598    cognitive.source_memory_ids = vec![left.id, right.id];
599    cognitive.confidence = Some(0.70);
600    cognitive.times_contradicted = 1;
601    cognitive.times_reinforced = 0;
602    cognitive.derived_at = Some(Utc::now());
603    cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
604
605    let metadata = cognitive.merge_into(&serde_json::json!({
606        "contradiction_source_ids": [left.id, right.id],
607    }));
608    let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
609
610    let memory = repo
611        .store_with_lineage(StoreMemoryWithLineageParams {
612            store: StoreMemoryParams {
613                namespace_id: left.namespace_id,
614                content: &content,
615                category: &MemoryCategory::Facts,
616                memory_lane_type: Some(&MemoryLaneType::Cognitive(
617                    MemoryLaneCognitiveType::Metamemory,
618                )),
619                labels: &[
620                    "reflection".to_string(),
621                    "contradiction".to_string(),
622                    "auto".to_string(),
623                ],
624                metadata: &metadata,
625                embedding: embedding.as_deref(),
626                embedding_model: embedding_model.as_deref(),
627            },
628            source_memory_ids: &[left.id, right.id],
629            evidence_role: CONTRADICT_EVIDENCE_ROLE,
630        })
631        .await
632        .map_err(|e| AgentError::Storage(e.to_string()))?;
633    // Combined increment + belief revision: one load, one parse, one write per memory.
634    // Previous code called increment_cognitive_counter and apply_belief_revision
635    // separately, causing 4 DB loads/writes for a single contradiction pair.
636    if cognition.contradiction_belief_revision_enabled {
637        increment_counter_and_revise_belief(
638            repo,
639            left.id,
640            cognition.contradiction_confidence_penalty,
641        )
642        .await?;
643        increment_counter_and_revise_belief(
644            repo,
645            right.id,
646            cognition.contradiction_confidence_penalty,
647        )
648        .await?;
649    } else {
650        increment_cognitive_counter(repo, left.id, "times_contradicted").await?;
651        increment_cognitive_counter(repo, right.id, "times_contradicted").await?;
652    }
653
654    debug!(
655        left_id = left.id,
656        right_id = right.id,
657        contradiction_id = memory.id,
658        "Created contradiction record"
659    );
660
661    Ok(memory.id)
662}
663
664async fn increment_cognitive_counter(
665    repo: &MemoryRepository,
666    memory_id: i64,
667    counter_key: &str,
668) -> Result<(), AgentError> {
669    let Some(memory) = repo
670        .get_by_id(memory_id)
671        .await
672        .map_err(|e| AgentError::Storage(e.to_string()))?
673    else {
674        return Ok(());
675    };
676
677    // Parse cognitive metadata exactly once.  The snapshot call was redundant
678    // here because we need the full CognitiveMetadata struct to merge_into
679    // anyway; the snapshot only stores a subset of its fields.
680    let mut cognitive = CognitiveMetadata::from_metadata(&memory.metadata).unwrap_or_else(|| {
681        CognitiveMetadata::new(CognitiveLevel::Raw, "unknown", "unknown", None, "recovery")
682    });
683
684    if cognitive.generated_by.is_none() {
685        cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
686    }
687
688    match counter_key {
689        "times_reinforced" => {
690            cognitive.times_reinforced = cognitive.times_reinforced.saturating_add(1);
691        }
692        "times_contradicted" => {
693            cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
694        }
695        _ => return Ok(()),
696    }
697
698    let merged = cognitive.merge_into(&memory.metadata);
699    repo.update_memory_metadata(memory_id, &merged)
700        .await
701        .map_err(|e| AgentError::Storage(e.to_string()))?;
702    Ok(())
703}
704
705/// Combined increment + belief revision for contradiction handling.
706///
707/// Performs both `increment_cognitive_counter("times_contradicted")` and
708/// `apply_belief_revision` in a single DB load/parse/write cycle, halving the
709/// round-trips compared to calling them separately.
710async fn increment_counter_and_revise_belief(
711    repo: &MemoryRepository,
712    memory_id: i64,
713    penalty: f32,
714) -> Result<(), AgentError> {
715    let Some(memory) = repo
716        .get_by_id(memory_id)
717        .await
718        .map_err(|e| AgentError::Storage(e.to_string()))?
719    else {
720        return Ok(());
721    };
722
723    let mut cognitive = match CognitiveMetadata::from_metadata(&memory.metadata) {
724        Some(c) => c,
725        None => return Ok(()),
726    };
727
728    cognitive.times_contradicted = cognitive.times_contradicted.saturating_add(1);
729    cognitive.confidence = Some((cognitive.confidence.unwrap_or(0.75) - penalty).max(0.0));
730    cognitive.last_belief_revision = Some(Utc::now());
731    cognitive.resolution_status = Some("revised".to_string());
732
733    let merged = cognitive.merge_into(&memory.metadata);
734    repo.update_memory_metadata(memory_id, &merged)
735        .await
736        .map_err(|e| AgentError::Storage(e.to_string()))?;
737    Ok(())
738}
739
740async fn synthesize_reinforcement_insights(
741    candidates: &[Memory],
742    reinforcement_pairs: &[(i64, i64)],
743    perspective: &PerspectiveKey,
744    repo: &MemoryRepository,
745    embeddings: Option<&dyn EmbeddingService>,
746) -> Result<Vec<i64>, AgentError> {
747    if reinforcement_pairs.is_empty() {
748        return Ok(Vec::new());
749    }
750
751    let candidate_by_id: HashMap<i64, &Memory> = candidates.iter().map(|m| (m.id, m)).collect();
752    let components = build_reinforcement_components(candidates, reinforcement_pairs);
753    let mut insight_ids = Vec::new();
754
755    for component in components {
756        if component.len() < MIN_INSIGHT_COMPONENT_SIZE {
757            continue;
758        }
759
760        let mut source_ids: Vec<i64> = component.into_iter().collect();
761        source_ids.sort_unstable();
762
763        if find_existing_component_memory(repo, &source_ids, INSIGHT_EVIDENCE_ROLE)
764            .await?
765            .is_some()
766        {
767            continue;
768        }
769
770        let component_memories: Vec<&Memory> = source_ids
771            .iter()
772            .filter_map(|id| candidate_by_id.get(id).copied())
773            .collect();
774        if component_memories.len() < MIN_INSIGHT_COMPONENT_SIZE {
775            continue;
776        }
777
778        let content = build_insight_content(&component_memories);
779        let mut cognitive = CognitiveMetadata::new(
780            CognitiveLevel::Derived,
781            perspective.observer.clone(),
782            perspective.subject.clone(),
783            perspective.session_key.clone(),
784            REFLECT_GENERATED_BY,
785        );
786        cognitive.source_memory_ids = source_ids.clone();
787        cognitive.confidence = Some(insight_confidence(component_memories.len()));
788        cognitive.times_reinforced = component_memories.len() as i64;
789        cognitive.times_contradicted = 0;
790        cognitive.derived_at = Some(Utc::now());
791        cognitive.generated_by = Some(REFLECT_GENERATED_BY.to_string());
792
793        let metadata = cognitive.merge_into(&serde_json::json!({
794            "reflection_kind": "insight",
795        }));
796        let (embedding, embedding_model) = maybe_embed(embeddings, &content).await;
797        let memory = repo
798            .store_with_lineage(StoreMemoryWithLineageParams {
799                store: StoreMemoryParams {
800                    namespace_id: component_memories[0].namespace_id,
801                    content: &content,
802                    category: &MemoryCategory::Facts,
803                    memory_lane_type: Some(&MemoryLaneType::Priority(
804                        MemoryLanePriorityType::Insight,
805                    )),
806                    labels: &[
807                        "reflection".to_string(),
808                        "insight".to_string(),
809                        "auto".to_string(),
810                    ],
811                    metadata: &metadata,
812                    embedding: embedding.as_deref(),
813                    embedding_model: embedding_model.as_deref(),
814                },
815                source_memory_ids: &source_ids,
816                evidence_role: INSIGHT_EVIDENCE_ROLE,
817            })
818            .await
819            .map_err(|e| AgentError::Storage(e.to_string()))?;
820        insight_ids.push(memory.id);
821    }
822
823    Ok(insight_ids)
824}
825
826fn build_reinforcement_components(
827    candidates: &[Memory],
828    reinforcement_pairs: &[(i64, i64)],
829) -> Vec<Vec<i64>> {
830    let mut adjacency: HashMap<i64, Vec<i64>> = HashMap::new();
831    for &(left, right) in reinforcement_pairs {
832        adjacency.entry(left).or_default().push(right);
833        adjacency.entry(right).or_default().push(left);
834    }
835
836    for i in 0..candidates.len() {
837        for j in (i + 1)..candidates.len() {
838            let left = &candidates[i];
839            let right = &candidates[j];
840            if matches!(
841                compare_pair(left, right),
842                Some(ReflectionCase::Contradiction)
843            ) {
844                continue;
845            }
846
847            if word_jaccard(&left.content, &right.content) >= INSIGHT_SIMILARITY_THRESHOLD {
848                adjacency.entry(left.id).or_default().push(right.id);
849                adjacency.entry(right.id).or_default().push(left.id);
850            }
851        }
852    }
853
854    let mut visited = HashSet::new();
855    let mut components = Vec::new();
856    for &node in adjacency.keys() {
857        if !visited.insert(node) {
858            continue;
859        }
860
861        let mut stack = vec![node];
862        let mut component = vec![node];
863        while let Some(current) = stack.pop() {
864            if let Some(neighbors) = adjacency.get(&current) {
865                for &neighbor in neighbors {
866                    if visited.insert(neighbor) {
867                        stack.push(neighbor);
868                        component.push(neighbor);
869                    }
870                }
871            }
872        }
873
874        component.sort_unstable();
875        components.push(component);
876    }
877
878    components
879}
880
881async fn find_existing_component_memory(
882    repo: &MemoryRepository,
883    source_ids: &[i64],
884    evidence_role: &str,
885) -> Result<Option<i64>, AgentError> {
886    let Some(&first_source_id) = source_ids.first() else {
887        return Ok(None);
888    };
889
890    let lineage_by_source = repo
891        .load_lineage_batch(source_ids)
892        .await
893        .map_err(|e| AgentError::Storage(e.to_string()))?;
894    let lineage = lineage_by_source
895        .get(&first_source_id)
896        .cloned()
897        .unwrap_or_default();
898    let candidate_ids: Vec<i64> = lineage
899        .into_iter()
900        .filter(|entry| {
901            entry.source_memory_id == first_source_id && entry.evidence_role == evidence_role
902        })
903        .map(|entry| entry.derived_memory_id)
904        .collect();
905
906    for derived_id in candidate_ids {
907        let matches_all = source_ids.iter().all(|source_id| {
908            lineage_by_source
909                .get(source_id)
910                .into_iter()
911                .flat_map(|entries| entries.iter())
912                .any(|entry| {
913                    entry.derived_memory_id == derived_id
914                        && entry.source_memory_id == *source_id
915                        && entry.evidence_role == evidence_role
916                })
917        });
918
919        if matches_all {
920            return Ok(Some(derived_id));
921        }
922    }
923
924    Ok(None)
925}
926
927fn build_insight_content(memories: &[&Memory]) -> String {
928    let representative = memories
929        .iter()
930        .min_by_key(|memory| memory.id)
931        .map(|memory| truncate_content(memory.content.trim(), MAX_INSIGHT_CONTENT_CHARS))
932        .unwrap_or("repeated observations");
933
934    format!(
935        "Dream insight: repeated evidence indicates {}",
936        representative
937    )
938}
939
940fn insight_confidence(component_size: usize) -> f32 {
941    (0.72 + ((component_size.saturating_sub(MIN_INSIGHT_COMPONENT_SIZE)) as f32 * 0.05)).min(0.92)
942}
943
944fn truncate_content(s: &str, max_chars: usize) -> &str {
945    if s.len() <= max_chars {
946        return s;
947    }
948    // Find a safe char boundary.
949    let mut end = max_chars;
950    while end > 0 && !s.is_char_boundary(end) {
951        end -= 1;
952    }
953    // Try to break at a word boundary.
954    if let Some(space_pos) = s[..end].rfind(' ') {
955        return &s[..space_pos];
956    }
957    &s[..end]
958}
959
960// ---------------------------------------------------------------------------
961// Tests
962// ---------------------------------------------------------------------------
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    use chrono::Utc;
969    use nexus_core::{Category, MemoryLanePriorityType};
970    use nexus_storage::repository::{NamespaceRepository, StoreMemoryParams};
971    use sqlx::sqlite::SqlitePoolOptions;
972
973    fn test_memory(id: i64, content: &str, metadata: serde_json::Value) -> Memory {
974        Memory {
975            id,
976            namespace_id: 1,
977            content: content.to_string(),
978            category: Category::Facts,
979            memory_lane_type: None,
980            labels: vec![],
981            metadata,
982            similarity_score: None,
983            relevance_score: None,
984            content_embedding: None,
985            embedding_model: None,
986            created_at: Utc::now(),
987            updated_at: None,
988            last_accessed: None,
989            is_active: true,
990            is_archived: false,
991            access_count: 0,
992        }
993    }
994
995    // ---- Helpers ----
996
997    async fn setup_repo() -> (sqlx::SqlitePool, MemoryRepository, i64) {
998        let pool = SqlitePoolOptions::new()
999            .max_connections(1)
1000            .connect("sqlite::memory:")
1001            .await
1002            .unwrap();
1003        nexus_storage::migrations::run_migrations(&pool)
1004            .await
1005            .unwrap();
1006        let namespace_repo = NamespaceRepository::new(pool.clone());
1007        let namespace = namespace_repo
1008            .get_or_create("reflect-test", "reflect-test")
1009            .await
1010            .unwrap();
1011        let repo = MemoryRepository::new(pool.clone());
1012        (pool, repo, namespace.id)
1013    }
1014
1015    fn explicit_metadata(observer: &str) -> serde_json::Value {
1016        let cognitive = CognitiveMetadata::new(
1017            CognitiveLevel::Explicit,
1018            observer,
1019            observer,
1020            None,
1021            "derive_service",
1022        );
1023        cognitive.merge_into(&serde_json::json!({}))
1024    }
1025
1026    fn derived_metadata(observer: &str) -> serde_json::Value {
1027        let cognitive = CognitiveMetadata::new(
1028            CognitiveLevel::Derived,
1029            observer,
1030            observer,
1031            None,
1032            "derive_service",
1033        );
1034        cognitive.merge_into(&serde_json::json!({}))
1035    }
1036
1037    fn raw_metadata() -> serde_json::Value {
1038        let cognitive = CognitiveMetadata::new(
1039            CognitiveLevel::Raw,
1040            "claude-code",
1041            "claude-code",
1042            None,
1043            "ingest_service",
1044        );
1045        cognitive.merge_into(&serde_json::json!({}))
1046    }
1047
1048    async fn store_memory(
1049        repo: &MemoryRepository,
1050        namespace_id: i64,
1051        content: &str,
1052        metadata: &serde_json::Value,
1053    ) -> Memory {
1054        repo.store(StoreMemoryParams {
1055            namespace_id,
1056            content,
1057            category: &MemoryCategory::Facts,
1058            memory_lane_type: Some(&MemoryLaneType::Priority(MemoryLanePriorityType::Decision)),
1059            labels: &["test".to_string()],
1060            metadata,
1061            embedding: None,
1062            embedding_model: None,
1063        })
1064        .await
1065        .unwrap()
1066    }
1067
1068    // ---- Unit tests: comparison logic ----
1069
1070    #[test]
1071    fn test_word_jaccard_identical() {
1072        assert!((word_jaccard("hello world", "hello world") - 1.0).abs() < f32::EPSILON);
1073    }
1074
1075    #[test]
1076    fn test_word_jaccard_disjoint() {
1077        assert!((word_jaccard("alpha beta", "gamma delta")).abs() < f32::EPSILON);
1078    }
1079
1080    #[test]
1081    fn test_word_jaccard_partial() {
1082        let j = word_jaccard(
1083            "the query service handles search",
1084            "the query service handles pagination",
1085        );
1086        assert!(j > 0.5, "expected partial overlap, got {}", j);
1087    }
1088
1089    #[test]
1090    fn test_word_jaccard_empty_strings() {
1091        assert!((word_jaccard("", "") - 1.0).abs() < f32::EPSILON);
1092    }
1093
1094    #[test]
1095    fn test_word_jaccard_one_empty() {
1096        assert!((word_jaccard("hello", "")).abs() < f32::EPSILON);
1097    }
1098
1099    #[test]
1100    fn test_compare_pair_similar_content_reinforces() {
1101        let left = test_memory(
1102            1,
1103            "The query service handles search requests",
1104            explicit_metadata("claude-code"),
1105        );
1106        let right = test_memory(
1107            2,
1108            "The query service handles search requests efficiently",
1109            explicit_metadata("claude-code"),
1110        );
1111
1112        assert_eq!(
1113            compare_pair(&left, &right),
1114            Some(ReflectionCase::Reinforcement)
1115        );
1116    }
1117
1118    #[test]
1119    fn test_compare_pair_contradiction_pattern() {
1120        let left = test_memory(
1121            1,
1122            "The cache system is enabled and improves performance",
1123            explicit_metadata("claude-code"),
1124        );
1125        let right = test_memory(
1126            2,
1127            "The cache system is not enabled and degrades performance",
1128            explicit_metadata("claude-code"),
1129        );
1130
1131        assert_eq!(
1132            compare_pair(&left, &right),
1133            Some(ReflectionCase::Contradiction)
1134        );
1135    }
1136
1137    #[test]
1138    fn test_compare_pair_unrelated() {
1139        let left = test_memory(
1140            1,
1141            "Fixed pagination bug in search endpoint",
1142            explicit_metadata("claude-code"),
1143        );
1144        let right = test_memory(
1145            2,
1146            "Updated deployment configuration for staging",
1147            explicit_metadata("claude-code"),
1148        );
1149
1150        assert_eq!(compare_pair(&left, &right), None);
1151    }
1152
1153    #[test]
1154    fn test_has_negation_contradiction_detects_negation() {
1155        assert!(has_negation_contradiction(
1156            "the feature is not working correctly",
1157            "the feature is working correctly"
1158        ));
1159    }
1160
1161    #[test]
1162    fn test_has_negation_contradiction_no_negation() {
1163        assert!(!has_negation_contradiction(
1164            "the feature works well",
1165            "the feature is fast"
1166        ));
1167    }
1168
1169    // ---- Integration tests: full cycle ----
1170
1171    #[tokio::test]
1172    async fn test_reflect_cycle_empty_namespace() {
1173        let (_pool, repo, namespace_id) = setup_repo().await;
1174        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1175
1176        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1177        assert_eq!(result.memories_scanned, 0);
1178        assert_eq!(result.pairs_compared, 0);
1179        assert_eq!(result.reinforcements, 0);
1180        assert_eq!(result.insights_created, 0);
1181        assert_eq!(result.contradictions_created, 0);
1182    }
1183
1184    #[tokio::test]
1185    async fn test_reflect_cycle_skips_raw_memories() {
1186        let (_pool, repo, namespace_id) = setup_repo().await;
1187
1188        store_memory(&repo, namespace_id, "raw noise event", &raw_metadata()).await;
1189
1190        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1191        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1192
1193        assert_eq!(result.memories_scanned, 0);
1194    }
1195
1196    #[tokio::test]
1197    async fn test_reflect_cycle_detects_reinforcement() {
1198        let (_pool, repo, namespace_id) = setup_repo().await;
1199
1200        let left = store_memory(
1201            &repo,
1202            namespace_id,
1203            "The query service handles search requests",
1204            &explicit_metadata("claude-code"),
1205        )
1206        .await;
1207        let right = store_memory(
1208            &repo,
1209            namespace_id,
1210            "The query service handles search requests efficiently",
1211            &explicit_metadata("claude-code"),
1212        )
1213        .await;
1214
1215        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1216        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1217
1218        assert_eq!(result.memories_scanned, 2);
1219        assert!(
1220            result.reinforcements >= 1,
1221            "expected at least 1 reinforcement"
1222        );
1223        assert_eq!(result.contradictions_created, 0);
1224
1225        let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1226        let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1227        let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1228        let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1229        assert_eq!(left_cognitive.times_reinforced, 1);
1230        assert_eq!(right_cognitive.times_reinforced, 1);
1231    }
1232
1233    #[tokio::test]
1234    async fn test_reflect_cycle_detects_contradiction() {
1235        let (_pool, repo, namespace_id) = setup_repo().await;
1236
1237        let left = store_memory(
1238            &repo,
1239            namespace_id,
1240            "The cache system is enabled and improves performance",
1241            &explicit_metadata("claude-code"),
1242        )
1243        .await;
1244        let right = store_memory(
1245            &repo,
1246            namespace_id,
1247            "The cache system is not enabled and degrades performance",
1248            &explicit_metadata("claude-code"),
1249        )
1250        .await;
1251
1252        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1253        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1254
1255        assert_eq!(result.memories_scanned, 2);
1256        assert_eq!(result.contradictions_created, 1);
1257        assert_eq!(result.contradiction_ids.len(), 1);
1258
1259        let left = repo.get_by_id(left.id).await.unwrap().unwrap();
1260        let right = repo.get_by_id(right.id).await.unwrap().unwrap();
1261        let left_cognitive = CognitiveMetadata::from_metadata(&left.metadata).unwrap();
1262        let right_cognitive = CognitiveMetadata::from_metadata(&right.metadata).unwrap();
1263        assert_eq!(left_cognitive.times_contradicted, 1);
1264        assert_eq!(right_cognitive.times_contradicted, 1);
1265    }
1266
1267    #[tokio::test]
1268    async fn test_reflect_cycle_is_idempotent() {
1269        let (_pool, repo, namespace_id) = setup_repo().await;
1270
1271        store_memory(
1272            &repo,
1273            namespace_id,
1274            "The query service handles search requests",
1275            &explicit_metadata("claude-code"),
1276        )
1277        .await;
1278        store_memory(
1279            &repo,
1280            namespace_id,
1281            "The query service handles search requests efficiently",
1282            &explicit_metadata("claude-code"),
1283        )
1284        .await;
1285
1286        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1287
1288        // First pass.
1289        let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1290        let first_reinforcements = result1.reinforcements;
1291
1292        // Second pass — should be idempotent.
1293        let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1294        assert_eq!(
1295            result2.reinforcements, 0,
1296            "second pass should not create duplicate reinforcements"
1297        );
1298        // First pass result is still valid.
1299        assert!(first_reinforcements >= 1);
1300    }
1301
1302    #[tokio::test]
1303    async fn test_reinforcement_creates_evidence_links() {
1304        let (_pool, repo, namespace_id) = setup_repo().await;
1305
1306        let m1 = store_memory(
1307            &repo,
1308            namespace_id,
1309            "The query service handles search requests",
1310            &explicit_metadata("claude-code"),
1311        )
1312        .await;
1313        let m2 = store_memory(
1314            &repo,
1315            namespace_id,
1316            "The query service handles search requests efficiently",
1317            &explicit_metadata("claude-code"),
1318        )
1319        .await;
1320
1321        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1322        service.reflect_cycle(namespace_id, &repo).await.unwrap();
1323
1324        // Verify evidence links exist: both m1 and m2 should share a reinforcement
1325        // link to the same reflection memory.
1326        let lineage1 = repo.load_lineage(m1.id).await.unwrap();
1327        let reinforcement_ids: Vec<i64> = lineage1
1328            .iter()
1329            .filter(|e| e.evidence_role == REINFORCE_EVIDENCE_ROLE)
1330            .map(|e| e.derived_memory_id)
1331            .collect();
1332
1333        let lineage2 = repo.load_lineage(m2.id).await.unwrap();
1334        let shared: Vec<i64> = lineage2
1335            .iter()
1336            .filter(|e| {
1337                e.evidence_role == REINFORCE_EVIDENCE_ROLE
1338                    && reinforcement_ids.contains(&e.derived_memory_id)
1339            })
1340            .map(|e| e.derived_memory_id)
1341            .collect();
1342
1343        assert!(
1344            !shared.is_empty(),
1345            "expected shared reinforcement memory linking both sources"
1346        );
1347    }
1348
1349    #[tokio::test]
1350    async fn test_contradiction_stores_with_correct_metadata() {
1351        let (_pool, repo, namespace_id) = setup_repo().await;
1352
1353        store_memory(
1354            &repo,
1355            namespace_id,
1356            "The cache system is enabled and improves performance",
1357            &explicit_metadata("claude-code"),
1358        )
1359        .await;
1360        store_memory(
1361            &repo,
1362            namespace_id,
1363            "The cache system is not enabled and degrades performance",
1364            &explicit_metadata("claude-code"),
1365        )
1366        .await;
1367
1368        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1369        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1370        assert_eq!(result.contradiction_ids.len(), 1);
1371
1372        // Fetch the contradiction memory and verify its cognitive metadata.
1373        let contradiction_id = result.contradiction_ids[0];
1374        let memories = repo
1375            .list_filtered(
1376                namespace_id,
1377                ListMemoryFilters {
1378                    category: None,
1379                    since: None,
1380                    until: None,
1381                    content_like: Some("Contradiction"),
1382                    include_raw: false,
1383                    limit: 10,
1384                    offset: 0,
1385                },
1386            )
1387            .await
1388            .unwrap();
1389
1390        let contradiction = memories
1391            .iter()
1392            .find(|m| m.id == contradiction_id)
1393            .expect("contradiction memory should be retrievable");
1394
1395        let cognitive = CognitiveMetadata::from_metadata(&contradiction.metadata)
1396            .expect("contradiction memory should have cognitive metadata");
1397        assert_eq!(cognitive.level, CognitiveLevel::Contradiction);
1398        assert_eq!(
1399            cognitive.generated_by,
1400            Some(REFLECT_GENERATED_BY.to_string())
1401        );
1402        assert_eq!(cognitive.source_memory_ids.len(), 2);
1403        assert!(cognitive.confidence.is_some());
1404        assert!(cognitive.confidence.unwrap() > 0.0);
1405
1406        // Verify evidence links.
1407        let lineage = repo.load_lineage(contradiction_id).await.unwrap();
1408        assert!(
1409            lineage
1410                .iter()
1411                .any(|e| e.evidence_role == CONTRADICT_EVIDENCE_ROLE),
1412            "contradiction memory should have contradicts evidence"
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn test_reflect_cycle_handles_derived_level() {
1418        let (_pool, repo, namespace_id) = setup_repo().await;
1419
1420        store_memory(
1421            &repo,
1422            namespace_id,
1423            "The query service handles search requests",
1424            &derived_metadata("claude-code"),
1425        )
1426        .await;
1427        store_memory(
1428            &repo,
1429            namespace_id,
1430            "The query service handles search requests efficiently",
1431            &derived_metadata("claude-code"),
1432        )
1433        .await;
1434
1435        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1436        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1437
1438        assert_eq!(result.memories_scanned, 2);
1439        assert!(result.reinforcements >= 1);
1440    }
1441
1442    #[tokio::test]
1443    async fn test_reflect_cycle_creates_higher_order_insight() {
1444        let (_pool, repo, namespace_id) = setup_repo().await;
1445
1446        for content in [
1447            "The query service handles search requests",
1448            "The query service handles search requests efficiently",
1449            "The query service handles search requests reliably",
1450        ] {
1451            store_memory(
1452                &repo,
1453                namespace_id,
1454                content,
1455                &explicit_metadata("claude-code"),
1456            )
1457            .await;
1458        }
1459
1460        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1461        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1462
1463        assert_eq!(result.insights_created, 1);
1464        assert_eq!(result.insight_ids.len(), 1);
1465
1466        let insight = repo
1467            .get_by_id(result.insight_ids[0])
1468            .await
1469            .unwrap()
1470            .unwrap();
1471        assert!(insight.content.starts_with("Dream insight:"));
1472        assert!(insight.labels.iter().any(|label| label == "insight"));
1473        let cognitive = CognitiveMetadata::from_metadata(&insight.metadata).unwrap();
1474        assert_eq!(cognitive.level, CognitiveLevel::Derived);
1475        assert_eq!(cognitive.source_memory_ids.len(), 3);
1476        assert_eq!(cognitive.times_reinforced, 3);
1477
1478        let lineage = repo.load_lineage(insight.id).await.unwrap();
1479        let evidence_count = lineage
1480            .iter()
1481            .filter(|entry| entry.evidence_role == INSIGHT_EVIDENCE_ROLE)
1482            .count();
1483        assert_eq!(evidence_count, 3);
1484    }
1485
1486    #[tokio::test]
1487    async fn test_reflect_cycle_contradiction_idempotent() {
1488        let (_pool, repo, namespace_id) = setup_repo().await;
1489
1490        store_memory(
1491            &repo,
1492            namespace_id,
1493            "The cache system is enabled and improves performance",
1494            &explicit_metadata("claude-code"),
1495        )
1496        .await;
1497        store_memory(
1498            &repo,
1499            namespace_id,
1500            "The cache system is not enabled and degrades performance",
1501            &explicit_metadata("claude-code"),
1502        )
1503        .await;
1504
1505        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1506
1507        let result1 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1508        assert_eq!(result1.contradictions_created, 1);
1509
1510        let result2 = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1511        assert_eq!(
1512            result2.contradictions_created, 0,
1513            "second pass should not create duplicate contradictions"
1514        );
1515    }
1516
1517    #[tokio::test]
1518    async fn test_reflect_cycle_insight_idempotent() {
1519        let (_pool, repo, namespace_id) = setup_repo().await;
1520
1521        for content in [
1522            "The query service handles search requests",
1523            "The query service handles search requests efficiently",
1524            "The query service handles search requests reliably",
1525        ] {
1526            store_memory(
1527                &repo,
1528                namespace_id,
1529                content,
1530                &explicit_metadata("claude-code"),
1531            )
1532            .await;
1533        }
1534
1535        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1536        let first = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1537        let second = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1538
1539        assert_eq!(first.insights_created, 1);
1540        assert_eq!(second.insights_created, 0);
1541
1542        let insights = repo
1543            .list_filtered(
1544                namespace_id,
1545                ListMemoryFilters {
1546                    category: None,
1547                    since: None,
1548                    until: None,
1549                    content_like: Some("Dream insight:"),
1550                    include_raw: false,
1551                    limit: 10,
1552                    offset: 0,
1553                },
1554            )
1555            .await
1556            .unwrap();
1557        assert_eq!(insights.len(), 1);
1558    }
1559
1560    #[tokio::test]
1561    async fn test_contradiction_gets_embedding_when_service_provided() {
1562        let (_pool, repo, namespace_id) = setup_repo().await;
1563
1564        store_memory(
1565            &repo,
1566            namespace_id,
1567            "The cache system is enabled and improves performance",
1568            &explicit_metadata("claude-code"),
1569        )
1570        .await;
1571        store_memory(
1572            &repo,
1573            namespace_id,
1574            "The cache system is not enabled and degrades performance",
1575            &explicit_metadata("claude-code"),
1576        )
1577        .await;
1578
1579        let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1580        let service = ReflectService::new(
1581            AgentConfig::default(),
1582            CognitionConfig::default(),
1583            Some(Arc::new(mock_embed)),
1584        );
1585        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1586
1587        assert_eq!(result.contradiction_ids.len(), 1);
1588
1589        let contradiction = repo
1590            .get_by_id(result.contradiction_ids[0])
1591            .await
1592            .unwrap()
1593            .unwrap();
1594        assert!(
1595            contradiction.content_embedding.is_some(),
1596            "contradiction memory should have an embedding when service is provided"
1597        );
1598        assert_eq!(
1599            contradiction.content_embedding.as_ref().unwrap().len(),
1600            384,
1601            "embedding dimension should be 384"
1602        );
1603    }
1604
1605    #[tokio::test]
1606    async fn test_insight_gets_embedding_when_service_provided() {
1607        let (_pool, repo, namespace_id) = setup_repo().await;
1608
1609        for content in [
1610            "The query service handles search requests",
1611            "The query service handles search requests efficiently",
1612            "The query service handles search requests reliably",
1613        ] {
1614            store_memory(
1615                &repo,
1616                namespace_id,
1617                content,
1618                &explicit_metadata("claude-code"),
1619            )
1620            .await;
1621        }
1622
1623        let mock_embed = nexus_embeddings::MockEmbeddingService::new();
1624        let service = ReflectService::new(
1625            AgentConfig::default(),
1626            CognitionConfig::default(),
1627            Some(Arc::new(mock_embed)),
1628        );
1629        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1630
1631        assert_eq!(result.insights_created, 1);
1632
1633        let insight = repo
1634            .get_by_id(result.insight_ids[0])
1635            .await
1636            .unwrap()
1637            .unwrap();
1638        assert!(
1639            insight.content_embedding.is_some(),
1640            "insight memory should have an embedding when service is provided"
1641        );
1642        assert_eq!(
1643            insight.content_embedding.as_ref().unwrap().len(),
1644            384,
1645            "embedding dimension should be 384"
1646        );
1647    }
1648
1649    #[tokio::test]
1650    async fn test_reflect_stores_without_embedding_when_service_absent() {
1651        let (_pool, repo, namespace_id) = setup_repo().await;
1652
1653        store_memory(
1654            &repo,
1655            namespace_id,
1656            "The cache system is enabled and improves performance",
1657            &explicit_metadata("claude-code"),
1658        )
1659        .await;
1660        store_memory(
1661            &repo,
1662            namespace_id,
1663            "The cache system is not enabled and degrades performance",
1664            &explicit_metadata("claude-code"),
1665        )
1666        .await;
1667
1668        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1669        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1670        assert_eq!(result.contradiction_ids.len(), 1);
1671
1672        let contradiction = repo
1673            .get_by_id(result.contradiction_ids[0])
1674            .await
1675            .unwrap()
1676            .unwrap();
1677        assert!(
1678            contradiction.content_embedding.is_none(),
1679            "contradiction memory should NOT have embedding when no service provided"
1680        );
1681    }
1682
1683    #[tokio::test]
1684    async fn test_reflect_cycle_does_not_cross_perspectives() {
1685        let (_pool, repo, namespace_id) = setup_repo().await;
1686
1687        store_memory(
1688            &repo,
1689            namespace_id,
1690            "The query service handles search requests",
1691            &explicit_metadata("claude-code"),
1692        )
1693        .await;
1694        store_memory(
1695            &repo,
1696            namespace_id,
1697            "The query service handles search requests efficiently",
1698            &explicit_metadata("codex"),
1699        )
1700        .await;
1701
1702        let service = ReflectService::new(AgentConfig::default(), CognitionConfig::default(), None);
1703        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1704
1705        assert_eq!(result.memories_scanned, 2);
1706        assert_eq!(result.pairs_compared, 0);
1707        assert_eq!(result.reinforcements, 0);
1708        assert_eq!(result.contradictions_created, 0);
1709    }
1710
1711    // ---- Belief revision tests (Phase 16) ----
1712
1713    #[tokio::test]
1714    async fn test_belief_revision_reduces_confidence_on_contradiction() {
1715        let (_pool, repo, namespace_id) = setup_repo().await;
1716
1717        let left = store_memory(
1718            &repo,
1719            namespace_id,
1720            "The cache system is enabled and improves performance",
1721            &explicit_metadata("claude-code"),
1722        )
1723        .await;
1724        let right = store_memory(
1725            &repo,
1726            namespace_id,
1727            "The cache system is not enabled and degrades performance",
1728            &explicit_metadata("claude-code"),
1729        )
1730        .await;
1731
1732        // Both start with default confidence (None → 0.75).
1733        let left_before = repo.get_by_id(left.id).await.unwrap().unwrap();
1734        let right_before = repo.get_by_id(right.id).await.unwrap().unwrap();
1735        let left_conf_before = CognitiveMetadata::from_metadata(&left_before.metadata)
1736            .unwrap()
1737            .confidence;
1738        let right_conf_before = CognitiveMetadata::from_metadata(&right_before.metadata)
1739            .unwrap()
1740            .confidence;
1741
1742        let service = ReflectService::new(
1743            AgentConfig::default(),
1744            CognitionConfig {
1745                contradiction_belief_revision_enabled: true,
1746                contradiction_confidence_penalty: 0.15,
1747                ..CognitionConfig::default()
1748            },
1749            None,
1750        );
1751        let result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1752
1753        assert_eq!(result.contradictions_created, 1);
1754
1755        let left_after = repo.get_by_id(left.id).await.unwrap().unwrap();
1756        let right_after = repo.get_by_id(right.id).await.unwrap().unwrap();
1757        let left_cognitive = CognitiveMetadata::from_metadata(&left_after.metadata).unwrap();
1758        let right_cognitive = CognitiveMetadata::from_metadata(&right_after.metadata).unwrap();
1759
1760        // Confidence should have decreased (use unwrap_or for Option comparison).
1761        assert!(
1762            left_cognitive.confidence.unwrap_or(0.75) < left_conf_before.unwrap_or(0.75),
1763            "left confidence should decrease after belief revision"
1764        );
1765        assert!(
1766            right_cognitive.confidence.unwrap_or(0.75) < right_conf_before.unwrap_or(0.75),
1767            "right confidence should decrease after belief revision"
1768        );
1769        // Belief revision timestamp should be set.
1770        assert!(left_cognitive.last_belief_revision.is_some());
1771        assert!(right_cognitive.last_belief_revision.is_some());
1772        // Resolution status should be "revised".
1773        assert_eq!(left_cognitive.resolution_status.as_deref(), Some("revised"));
1774        assert_eq!(
1775            right_cognitive.resolution_status.as_deref(),
1776            Some("revised")
1777        );
1778    }
1779
1780    #[tokio::test]
1781    async fn test_belief_revision_disabled_does_not_modify_confidence() {
1782        let (_pool, repo, namespace_id) = setup_repo().await;
1783
1784        store_memory(
1785            &repo,
1786            namespace_id,
1787            "The cache system is enabled",
1788            &explicit_metadata("claude-code"),
1789        )
1790        .await;
1791        store_memory(
1792            &repo,
1793            namespace_id,
1794            "The cache system is not enabled",
1795            &explicit_metadata("claude-code"),
1796        )
1797        .await;
1798
1799        let service = ReflectService::new(
1800            AgentConfig::default(),
1801            CognitionConfig {
1802                contradiction_belief_revision_enabled: false,
1803                ..CognitionConfig::default()
1804            },
1805            None,
1806        );
1807        let _result = service.reflect_cycle(namespace_id, &repo).await.unwrap();
1808
1809        // Check that no memories got belief revision stamps.
1810        let memories = repo
1811            .list_filtered(
1812                namespace_id,
1813                ListMemoryFilters {
1814                    category: None,
1815                    since: None,
1816                    until: None,
1817                    content_like: None,
1818                    include_raw: false,
1819                    limit: 10,
1820                    offset: 0,
1821                },
1822            )
1823            .await
1824            .unwrap();
1825
1826        for memory in memories {
1827            if let Some(cognitive) = CognitiveMetadata::from_metadata(&memory.metadata) {
1828                assert!(
1829                    cognitive.last_belief_revision.is_none(),
1830                    "no belief revision should occur when disabled"
1831                );
1832                assert!(
1833                    cognitive.resolution_status.is_none(),
1834                    "no resolution status when belief revision disabled"
1835                );
1836            }
1837        }
1838    }
1839}