Skip to main content

forge_reasoning/gaps/
analyzer.rs

1//! Knowledge gap analyzer core types
2//!
3//! Provides the main data structures for tracking knowledge gaps with multi-factor
4//! priority scoring and auto-close capabilities.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::hypothesis::HypothesisBoard;
13use crate::hypothesis::types::HypothesisId;
14use crate::belief::BeliefGraph;
15
16/// Unique identifier for a knowledge gap
17///
18/// UUID v4 wrapper following the same pattern as CheckpointId and HypothesisId.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct GapId(uuid::Uuid);
21
22impl GapId {
23    /// Create a new random GapId
24    pub fn new() -> Self {
25        Self(uuid::Uuid::new_v4())
26    }
27
28    /// Create GapId from UUID bytes
29    pub fn from_bytes(bytes: [u8; 16]) -> Self {
30        Self(uuid::Uuid::from_bytes(bytes))
31    }
32
33    /// Get GapId as UUID bytes
34    pub fn as_bytes(&self) -> [u8; 16] {
35        self.0.as_bytes().clone()
36    }
37}
38
39impl Default for GapId {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl std::fmt::Display for GapId {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.0)
48    }
49}
50
51/// Criticality level of a knowledge gap
52///
53/// Determines the base priority weight in scoring calculations.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55pub enum GapCriticality {
56    /// Low priority gap - nice to have but not blocking
57    Low,
58    /// Medium priority gap - important but not urgent
59    Medium,
60    /// High priority gap - blocking investigation
61    High,
62}
63
64/// Type of knowledge gap
65///
66/// Categorizes the nature of the missing information for context-aware suggestions.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub enum GapType {
69    /// Missing data or facts needed for investigation
70    MissingInformation,
71    /// Hypothesis or assumption not yet verified
72    UntestedAssumption,
73    /// Conflicting evidence or contradictory signals
74    ContradictoryEvidence,
75    /// Unknown relationship or dependency between hypotheses
76    UnknownDependency,
77    /// Flexible catch-all for other gap types
78    Other(String),
79}
80
81/// A knowledge gap with scoring factors
82///
83/// Tracks missing information with computed priority score based on multiple factors:
84/// - Criticality (High/Medium/Low)
85/// - Dependency depth (deeper = higher priority)
86/// - Evidence strength (less evidence = higher priority)
87/// - Age (older gaps = higher priority, capped at 30 days)
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct KnowledgeGap {
90    /// Unique identifier
91    pub id: GapId,
92    /// Human-readable description
93    pub description: String,
94    /// Links to hypothesis if applicable
95    pub hypothesis_id: Option<HypothesisId>,
96    /// Criticality level
97    pub criticality: GapCriticality,
98    /// Type of gap
99    pub gap_type: GapType,
100    /// When the gap was registered
101    pub created_at: DateTime<Utc>,
102    /// When the gap was filled (None if still open)
103    pub filled_at: Option<DateTime<Utc>>,
104    /// Resolution notes if filled
105    pub resolution_notes: Option<String>,
106    /// Computed multi-factor priority score (0.0 to 1.0)
107    pub score: f64,
108    /// Dependency depth (0 if no hypothesis)
109    pub depth: usize,
110    /// Average evidence strength at linked hypothesis
111    pub evidence_strength: f64,
112}
113
114/// Scoring configuration for multi-factor gap priority
115///
116/// Weights for each factor in the priority score calculation.
117/// All weights should sum to 1.0 for normalized scoring.
118#[derive(Clone, Debug)]
119pub struct ScoringConfig {
120    /// Weight for criticality factor (default: 0.5)
121    pub criticality_weight: f64,
122    /// Weight for dependency depth factor (default: 0.3)
123    pub depth_weight: f64,
124    /// Weight for evidence strength factor (default: 0.15)
125    pub evidence_weight: f64,
126    /// Weight for age factor (default: 0.05)
127    pub age_weight: f64,
128    /// Auto-close threshold for hypothesis confidence (default: 0.9)
129    pub auto_close_threshold: f64,
130}
131
132impl Default for ScoringConfig {
133    fn default() -> Self {
134        Self {
135            criticality_weight: 0.5,
136            depth_weight: 0.3,
137            evidence_weight: 0.15,
138            age_weight: 0.05,
139            auto_close_threshold: 0.9,
140        }
141    }
142}
143
144/// Action suggestion for resolving a knowledge gap
145///
146/// Generated based on gap type, hypothesis context, and dependency relationships.
147#[derive(Clone, Debug, Serialize, Deserialize)]
148pub struct GapSuggestion {
149    /// ID of the gap this suggestion addresses
150    pub gap_id: GapId,
151    /// Suggested action to take
152    pub action: SuggestedAction,
153    /// Human-readable rationale for this suggestion
154    pub rationale: String,
155    /// Priority score (copied from gap)
156    pub priority: f64,
157}
158
159/// Suggested action for resolving a knowledge gap
160///
161/// Action types are context-aware based on gap type and linked hypothesis state.
162#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
163pub enum SuggestedAction {
164    /// Run a specific test to gather evidence
165    RunTest { test_name: String },
166    /// Investigate a specific area
167    Investigate { area: String, details: String },
168    /// Gather evidence for a hypothesis
169    GatherEvidence { hypothesis_id: HypothesisId },
170    /// Resolve a dependency between hypotheses
171    ResolveDependency { dependent_id: HypothesisId, dependee_id: HypothesisId },
172    /// Create a verification check for a hypothesis
173    CreateVerificationCheck { command: String, hypothesis_id: HypothesisId },
174    /// Research a specific topic
175    Research { topic: String },
176    /// Other action (flexible catch-all)
177    Other { description: String },
178}
179
180/// Knowledge gap analyzer with multi-factor priority scoring
181///
182/// Main API for registering, tracking, and resolving knowledge gaps.
183/// Automatically computes priority scores and provides action suggestions.
184pub struct KnowledgeGapAnalyzer {
185    /// Hypothesis board for confidence queries
186    board: Arc<HypothesisBoard>,
187    /// Belief dependency graph for depth queries
188    graph: Arc<BeliefGraph>,
189    /// All gaps (filled and unfilled)
190    gaps: HashMap<GapId, KnowledgeGap>,
191    /// Scoring configuration
192    scoring_config: ScoringConfig,
193}
194
195impl KnowledgeGapAnalyzer {
196    /// Create new analyzer with default scoring config
197    pub fn new(board: Arc<HypothesisBoard>, graph: Arc<BeliefGraph>) -> Self {
198        Self {
199            board,
200            graph,
201            gaps: HashMap::new(),
202            scoring_config: ScoringConfig::default(),
203        }
204    }
205
206    /// Set custom scoring configuration (builder pattern)
207    pub fn with_scoring_config(mut self, config: ScoringConfig) -> Self {
208        self.scoring_config = config;
209        self
210    }
211
212    /// Register a new knowledge gap
213    ///
214    /// Computes initial depth and evidence strength from linked hypothesis.
215    pub async fn register_gap(
216        &mut self,
217        description: String,
218        criticality: GapCriticality,
219        gap_type: GapType,
220        hypothesis_id: Option<HypothesisId>,
221    ) -> crate::errors::Result<GapId> {
222        let id = GapId::new();
223        let created_at = Utc::now();
224
225        // Compute depth from dependency graph
226        let depth = if let Some(hid) = hypothesis_id {
227            self.compute_depth(hid).await
228        } else {
229            0
230        };
231
232        // Compute evidence strength from hypothesis board
233        let evidence_strength = if let Some(hid) = hypothesis_id {
234            self.compute_evidence_strength(hid).await
235        } else {
236            0.0
237        };
238
239        // Create gap
240        let gap = KnowledgeGap {
241            id,
242            description,
243            hypothesis_id,
244            criticality,
245            gap_type,
246            created_at,
247            filled_at: None,
248            resolution_notes: None,
249            score: 0.0, // Will be computed below
250            depth,
251            evidence_strength,
252        };
253
254        // Compute initial score
255        let score = super::scoring::compute_gap_score(&gap, &self.scoring_config);
256
257        let mut gap = gap;
258        gap.score = score;
259
260        self.gaps.insert(id, gap);
261        Ok(id)
262    }
263
264    /// Mark a gap as filled with resolution notes
265    pub async fn fill_gap(
266        &mut self,
267        gap_id: GapId,
268        resolution_notes: String,
269    ) -> crate::errors::Result<()> {
270        let gap = self.gaps.get_mut(&gap_id)
271            .ok_or_else(|| crate::errors::ReasoningError::NotFound(
272                format!("Gap {} not found", gap_id)
273            ))?;
274
275        gap.filled_at = Some(Utc::now());
276        gap.resolution_notes = Some(resolution_notes);
277        Ok(())
278    }
279
280    /// List all gaps, optionally filtering unfilled only
281    ///
282    /// Returns gaps sorted by score descending (highest priority first).
283    pub async fn list_gaps(&self, unfilled_only: bool) -> Vec<KnowledgeGap> {
284        let mut gaps: Vec<_> = self.gaps.values()
285            .filter(|g| !unfilled_only || g.filled_at.is_none())
286            .cloned()
287            .collect();
288
289        // Sort by score descending (highest priority first)
290        gaps.sort_by(|a, b| {
291            b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)
292        });
293
294        gaps
295    }
296
297    /// Get a specific gap by ID
298    pub fn get_gap(&self, gap_id: GapId) -> Option<&KnowledgeGap> {
299        self.gaps.get(&gap_id)
300    }
301
302    /// Auto-close gaps where linked hypothesis reached high confidence
303    ///
304    /// Returns list of closed gap IDs.
305    pub async fn auto_close_gaps(&mut self) -> Vec<GapId> {
306        let mut closed = Vec::new();
307
308        for gap in self.gaps.values_mut() {
309            // Only consider unfilled gaps with linked hypothesis
310            if gap.filled_at.is_some() {
311                continue;
312            }
313
314            let hypothesis_id = match gap.hypothesis_id {
315                Some(hid) => hid,
316                None => continue,
317            };
318
319            // Query hypothesis confidence
320            let hypothesis = match self.board.get(hypothesis_id).await {
321                Ok(Some(h)) => h,
322                _ => continue,
323            };
324
325            // Check if confidence exceeds threshold
326            if hypothesis.current_confidence().get() > self.scoring_config.auto_close_threshold {
327                gap.filled_at = Some(Utc::now());
328                gap.resolution_notes = Some(
329                    "Auto-closed: hypothesis reached high confidence".to_string()
330                );
331                closed.push(gap.id);
332            }
333        }
334
335        closed
336    }
337
338    /// Get action suggestions for gaps
339    ///
340    /// Returns context-aware suggestions sorted by priority.
341    pub async fn get_suggestions(&self, unfilled_only: bool) -> Vec<GapSuggestion> {
342        let gaps = self.list_gaps(unfilled_only).await;
343        super::suggestions::generate_all_suggestions(&gaps, &self.graph)
344    }
345
346    /// Recompute all gap scores (useful after config changes)
347    pub fn recompute_scores(&mut self) {
348        super::scoring::recompute_all_scores(&mut self.gaps, &self.scoring_config);
349    }
350
351    /// Compute dependency depth for a hypothesis
352    ///
353    /// Returns maximum depth of dependency chain (longest path to root).
354    async fn compute_depth(&self, hypothesis_id: HypothesisId) -> usize {
355        // Get full dependency chain
356        match self.graph.dependency_chain(hypothesis_id) {
357            Ok(chain) => chain.len(),
358            Err(_) => 0,
359        }
360    }
361
362    /// Compute average evidence strength for a hypothesis
363    async fn compute_evidence_strength(&self, hypothesis_id: HypothesisId) -> f64 {
364        match self.board.list_evidence(hypothesis_id).await {
365            Ok(evidence_list) => {
366                if evidence_list.is_empty() {
367                    0.0
368                } else {
369                    let total: f64 = evidence_list.iter()
370                        .map(|e| e.strength().abs())
371                        .sum();
372                    total / evidence_list.len() as f64
373                }
374            }
375            Err(_) => 0.0,
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::hypothesis::confidence::Confidence;
384
385    #[tokio::test]
386    async fn test_gap_id_uniqueness() {
387        let id1 = GapId::new();
388        let id2 = GapId::new();
389        assert_ne!(id1, id2);
390    }
391
392    #[tokio::test]
393    async fn test_scoring_config_default() {
394        let config = ScoringConfig::default();
395        assert_eq!(config.criticality_weight, 0.5);
396        assert_eq!(config.depth_weight, 0.3);
397        assert_eq!(config.evidence_weight, 0.15);
398        assert_eq!(config.age_weight, 0.05);
399        assert_eq!(config.auto_close_threshold, 0.9);
400    }
401
402    #[tokio::test]
403    async fn test_register_gap() {
404        let board = Arc::new(HypothesisBoard::in_memory());
405        let graph = Arc::new(BeliefGraph::new());
406        let mut analyzer = KnowledgeGapAnalyzer::new(board, graph);
407
408        let gap_id = analyzer.register_gap(
409            "Test gap".to_string(),
410            GapCriticality::Medium,
411            GapType::MissingInformation,
412            None,
413        ).await.unwrap();
414
415        let gap = analyzer.get_gap(gap_id);
416        assert!(gap.is_some());
417        assert_eq!(gap.unwrap().description, "Test gap");
418    }
419
420    #[tokio::test]
421    async fn test_fill_gap() {
422        let board = Arc::new(HypothesisBoard::in_memory());
423        let graph = Arc::new(BeliefGraph::new());
424        let mut analyzer = KnowledgeGapAnalyzer::new(board, graph);
425
426        let gap_id = analyzer.register_gap(
427            "Test gap".to_string(),
428            GapCriticality::Low,
429            GapType::UntestedAssumption,
430            None,
431        ).await.unwrap();
432
433        analyzer.fill_gap(gap_id, "Resolved".to_string()).await.unwrap();
434
435        let gap = analyzer.get_gap(gap_id).unwrap();
436        assert!(gap.filled_at.is_some());
437        assert_eq!(gap.resolution_notes, Some("Resolved".to_string()));
438    }
439
440    #[tokio::test]
441    async fn test_list_gaps_sorts_by_priority() {
442        let board = Arc::new(HypothesisBoard::in_memory());
443        let graph = Arc::new(BeliefGraph::new());
444        let mut analyzer = KnowledgeGapAnalyzer::new(board, graph);
445
446        // Register gaps with different criticality
447        analyzer.register_gap(
448            "Low priority".to_string(),
449            GapCriticality::Low,
450            GapType::MissingInformation,
451            None,
452        ).await.unwrap();
453
454        analyzer.register_gap(
455            "High priority".to_string(),
456            GapCriticality::High,
457            GapType::MissingInformation,
458            None,
459        ).await.unwrap();
460
461        let gaps = analyzer.list_gaps(false).await;
462        // High priority should come first
463        assert_eq!(gaps[0].criticality, GapCriticality::High);
464        assert_eq!(gaps[1].criticality, GapCriticality::Low);
465    }
466
467    #[tokio::test]
468    async fn test_register_gap_computes_score_correctly() {
469        let board = Arc::new(HypothesisBoard::in_memory());
470        let graph = Arc::new(BeliefGraph::new());
471        let mut analyzer = KnowledgeGapAnalyzer::new(board, graph);
472
473        let gap_id = analyzer.register_gap(
474            "Test gap".to_string(),
475            GapCriticality::High,
476            GapType::MissingInformation,
477            None,
478        ).await.unwrap();
479
480        let gap = analyzer.get_gap(gap_id).unwrap();
481        // Score should be > 0 since High criticality
482        assert!(gap.score > 0.0);
483        assert!(gap.score <= 1.0);
484    }
485
486    #[tokio::test]
487    async fn test_auto_close_gaps_closes_high_confidence_hypotheses() {
488        let board = Arc::new(HypothesisBoard::in_memory());
489        let graph = Arc::new(BeliefGraph::new());
490        let mut analyzer = KnowledgeGapAnalyzer::new(board.clone(), graph);
491
492        // Create hypothesis with high confidence
493        let prior = Confidence::new(0.95).unwrap();
494        let h_id = board.propose("High confidence hypothesis", prior).await.unwrap();
495
496        // Register gap linked to this hypothesis
497        let gap_id = analyzer.register_gap(
498            "Test gap".to_string(),
499            GapCriticality::Medium,
500            GapType::UntestedAssumption,
501            Some(h_id),
502        ).await.unwrap();
503
504        // Auto-close should close this gap
505        let closed = analyzer.auto_close_gaps().await;
506        assert_eq!(closed.len(), 1);
507        assert_eq!(closed[0], gap_id);
508
509        // Verify gap is filled
510        let gap = analyzer.get_gap(gap_id).unwrap();
511        assert!(gap.filled_at.is_some());
512        assert!(gap.resolution_notes.is_some());
513    }
514
515    #[tokio::test]
516    async fn test_get_suggestions_returns_sorted_list() {
517        let board = Arc::new(HypothesisBoard::in_memory());
518        let graph = Arc::new(BeliefGraph::new());
519        let mut analyzer = KnowledgeGapAnalyzer::new(board, graph);
520
521        // Register gaps
522        analyzer.register_gap(
523            "Low priority".to_string(),
524            GapCriticality::Low,
525            GapType::MissingInformation,
526            None,
527        ).await.unwrap();
528
529        analyzer.register_gap(
530            "High priority".to_string(),
531            GapCriticality::High,
532            GapType::UntestedAssumption,
533            None,
534        ).await.unwrap();
535
536        // Get suggestions
537        let suggestions = analyzer.get_suggestions(true).await;
538
539        // Should have 2 suggestions
540        assert_eq!(suggestions.len(), 2);
541
542        // Should be sorted by priority
543        assert!(suggestions[0].priority >= suggestions[1].priority);
544    }
545
546    #[tokio::test]
547    async fn test_recompute_scores_updates_all_gaps() {
548        let board = Arc::new(HypothesisBoard::in_memory());
549        let graph = Arc::new(BeliefGraph::new());
550        let mut analyzer = KnowledgeGapAnalyzer::new(board.clone(), graph);
551
552        // Register gap
553        let gap_id = analyzer.register_gap(
554            "Test gap".to_string(),
555            GapCriticality::Medium,
556            GapType::MissingInformation,
557            None,
558        ).await.unwrap();
559
560        // Manually set score to wrong value
561        {
562            let gap = analyzer.gaps.get_mut(&gap_id).unwrap();
563            gap.score = 0.0;
564        }
565
566        // Recompute scores
567        analyzer.recompute_scores();
568
569        // Score should be corrected
570        let gap = analyzer.get_gap(gap_id).unwrap();
571        assert!(gap.score > 0.0);
572    }
573
574    #[tokio::test]
575    async fn test_depth_computation_matches_dependency_graph() {
576        let board = Arc::new(HypothesisBoard::in_memory());
577
578        // Create hypothesis chain: H1 -> H2 -> H3
579        let prior = Confidence::new(0.5).unwrap();
580        let h1 = board.propose("H1", prior).await.unwrap();
581        let h2 = board.propose("H2", prior).await.unwrap();
582        let h3 = board.propose("H3", prior).await.unwrap();
583
584        // Create graph and add dependencies: H1 depends on H2, H2 depends on H3
585        let mut graph = BeliefGraph::new();
586        graph.add_dependency(h1, h2).unwrap();
587        graph.add_dependency(h2, h3).unwrap();
588
589        // Now create analyzer with the populated graph
590        let mut analyzer = KnowledgeGapAnalyzer::new(board.clone(), Arc::new(graph));
591
592        // Register gap for H1
593        let gap_id = analyzer.register_gap(
594            "Test gap".to_string(),
595            GapCriticality::Medium,
596            GapType::UntestedAssumption,
597            Some(h1),
598        ).await.unwrap();
599
600        // Depth should be 2 (H1 -> H2 -> H3)
601        let gap = analyzer.get_gap(gap_id).unwrap();
602        assert_eq!(gap.depth, 2);
603    }
604}