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