Skip to main content

forge_reasoning/gaps/
scoring.rs

1//! Multi-factor gap priority scoring with BinaryHeap
2//!
3//! Computes priority scores for knowledge gaps based on:
4//! - Criticality (High/Medium/Low)
5//! - Dependency depth (deeper = higher priority)
6//! - Evidence strength (less evidence = higher priority)
7//! - Age (older = higher priority, capped at 30 days)
8
9use std::collections::BinaryHeap;
10use std::cmp::Ordering;
11
12use chrono::Utc;
13
14use super::{KnowledgeGap, GapCriticality, ScoringConfig, GapId};
15
16/// Compute multi-factor priority score for a knowledge gap
17///
18/// Returns a score in range [0.0, 1.0] where higher = higher priority.
19///
20/// Factors:
21/// - criticality_score: High=1.0, Medium=0.6, Low=0.3
22/// - depth_score: min(depth / 10.0, 1.0) - normalized
23/// - evidence_score: 1.0 - abs(strength).clamp(0.0, 1.0)
24/// - age_score: min(days / 30.0, 1.0) - capped at 30 days
25/// - final_score: weighted sum using ScoringConfig
26pub fn compute_gap_score(gap: &KnowledgeGap, config: &ScoringConfig) -> f64 {
27    // Criticality score
28    let criticality_score = match gap.criticality {
29        GapCriticality::High => 1.0,
30        GapCriticality::Medium => 0.6,
31        GapCriticality::Low => 0.3,
32    };
33
34    // Depth score (normalize, cap at 10 levels)
35    let depth_score = (gap.depth as f64 / 10.0).min(1.0);
36
37    // Evidence score (less evidence = higher priority)
38    let evidence_score = 1.0 - gap.evidence_strength.abs().clamp(0.0, 1.0);
39
40    // Age score (older = higher priority, capped at 30 days)
41    let now = Utc::now();
42    let days_since_creation = (now.signed_duration_since(gap.created_at).num_days()).max(0) as f64;
43    let age_score = (days_since_creation / 30.0).min(1.0);
44
45    // Weighted sum
46    let score = criticality_score * config.criticality_weight
47        + depth_score * config.depth_weight
48        + evidence_score * config.evidence_weight
49        + age_score * config.age_weight;
50
51    score.clamp(0.0, 1.0)
52}
53
54/// Ord implementation for KnowledgeGap priority ordering
55///
56/// BinaryHeap is max-heap, so we reverse for min-first priority.
57/// Higher score gaps should have lower Ord value to pop first.
58#[derive(Clone, Debug)]
59struct ReverseKnowledgeGap {
60    gap: KnowledgeGap,
61}
62
63impl PartialEq for ReverseKnowledgeGap {
64    fn eq(&self, other: &Self) -> bool {
65        self.gap.id == other.gap.id
66    }
67}
68
69impl Eq for ReverseKnowledgeGap {}
70
71impl PartialOrd for ReverseKnowledgeGap {
72    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
73        Some(self.cmp(other))
74    }
75}
76
77impl Ord for ReverseKnowledgeGap {
78    fn cmp(&self, other: &Self) -> Ordering {
79        // Higher score = pops first from max-heap
80        // BinaryHeap is max-heap: largest element per Ord comes out first
81        match self.gap.score.partial_cmp(&other.gap.score) {
82            Some(Ordering::Equal) => {
83                // Tiebreaker: older gaps first (reverse ordering)
84                other.gap.created_at.cmp(&self.gap.created_at)
85            }
86            Some(order) => order,
87            None => Ordering::Equal,
88        }
89    }
90}
91
92/// Priority queue for knowledge gaps
93///
94/// Provides efficient access to highest priority gaps using BinaryHeap.
95pub struct PriorityQueue {
96    inner: BinaryHeap<ReverseKnowledgeGap>,
97}
98
99impl PriorityQueue {
100    /// Create empty priority queue
101    pub fn new() -> Self {
102        Self {
103            inner: BinaryHeap::new(),
104        }
105    }
106
107    /// Push a gap into the queue
108    pub fn push(&mut self, gap: KnowledgeGap) {
109        self.inner.push(ReverseKnowledgeGap { gap });
110    }
111
112    /// Pop highest priority gap
113    ///
114    /// Returns None if queue is empty.
115    pub fn pop(&mut self) -> Option<KnowledgeGap> {
116        self.inner.pop().map(|r| r.gap)
117    }
118
119    /// Peek at highest priority gap without removing
120    pub fn peek(&self) -> Option<&KnowledgeGap> {
121        self.inner.peek().map(|r| &r.gap)
122    }
123
124    /// Get number of gaps in queue
125    pub fn len(&self) -> usize {
126        self.inner.len()
127    }
128
129    /// Check if queue is empty
130    pub fn is_empty(&self) -> bool {
131        self.inner.is_empty()
132    }
133}
134
135impl Default for PriorityQueue {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141/// Recompute scores for all gaps
142///
143/// Useful when scoring config changes or evidence updates.
144pub fn recompute_all_scores(
145    gaps: &mut std::collections::HashMap<GapId, KnowledgeGap>,
146    config: &ScoringConfig,
147) {
148    for gap in gaps.values_mut() {
149        gap.score = compute_gap_score(gap, config);
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::hypothesis::types::HypothesisId;
157
158    fn make_test_gap(criticality: GapCriticality, depth: usize, evidence_strength: f64, days_old: i64) -> KnowledgeGap {
159        let id = GapId::new();
160        let created_at = Utc::now() - chrono::Duration::days(days_old);
161
162        KnowledgeGap {
163            id,
164            description: "Test gap".to_string(),
165            hypothesis_id: Some(HypothesisId::new()),
166            criticality,
167            gap_type: crate::gaps::analyzer::GapType::MissingInformation,
168            created_at,
169            filled_at: None,
170            resolution_notes: None,
171            score: 0.0,
172            depth,
173            evidence_strength,
174        }
175    }
176
177    #[test]
178    fn test_high_criticality_scores_higher() {
179        let config = ScoringConfig::default();
180        let high = make_test_gap(GapCriticality::High, 0, 0.0, 0);
181        let low = make_test_gap(GapCriticality::Low, 0, 0.0, 0);
182
183        let score_high = compute_gap_score(&high, &config);
184        let score_low = compute_gap_score(&low, &config);
185
186        assert!(score_high > score_low);
187    }
188
189    #[test]
190    fn test_deeper_gaps_score_higher() {
191        let config = ScoringConfig::default();
192        let shallow = make_test_gap(GapCriticality::Medium, 1, 0.0, 0);
193        let deep = make_test_gap(GapCriticality::Medium, 8, 0.0, 0);
194
195        let score_shallow = compute_gap_score(&shallow, &config);
196        let score_deep = compute_gap_score(&deep, &config);
197
198        assert!(score_deep > score_shallow);
199    }
200
201    #[test]
202    fn test_less_evidence_scores_higher() {
203        let config = ScoringConfig::default();
204        let no_evidence = make_test_gap(GapCriticality::Medium, 0, 0.0, 0);
205        let strong_evidence = make_test_gap(GapCriticality::Medium, 0, 0.9, 0);
206
207        let score_no = compute_gap_score(&no_evidence, &config);
208        let score_strong = compute_gap_score(&strong_evidence, &config);
209
210        assert!(score_no > score_strong);
211    }
212
213    #[test]
214    fn test_older_gaps_score_higher() {
215        let config = ScoringConfig::default();
216        let new_gap = make_test_gap(GapCriticality::Medium, 0, 0.0, 0);
217        let old_gap = make_test_gap(GapCriticality::Medium, 0, 0.0, 20);
218
219        let score_new = compute_gap_score(&new_gap, &config);
220        let score_old = compute_gap_score(&old_gap, &config);
221
222        assert!(score_old > score_new);
223    }
224
225    #[test]
226    fn test_age_capped_at_30_days() {
227        let config = ScoringConfig::default();
228        let gap_30 = make_test_gap(GapCriticality::Medium, 0, 0.0, 30);
229        let gap_100 = make_test_gap(GapCriticality::Medium, 0, 0.0, 100);
230
231        let score_30 = compute_gap_score(&gap_30, &config);
232        let score_100 = compute_gap_score(&gap_100, &config);
233
234        assert_eq!(score_30, score_100);
235    }
236
237    #[test]
238    fn test_weight_changes_affect_score() {
239        let mut config = ScoringConfig::default();
240
241        // Make criticality dominant
242        config.criticality_weight = 1.0;
243        config.depth_weight = 0.0;
244        config.evidence_weight = 0.0;
245        config.age_weight = 0.0;
246
247        let gap = make_test_gap(GapCriticality::High, 0, 0.0, 0);
248        let score = compute_gap_score(&gap, &config);
249
250        assert_eq!(score, 1.0); // Only criticality matters
251    }
252
253    #[test]
254    fn test_priority_queue_returns_highest_priority_first() {
255        let config = ScoringConfig::default();
256
257        let mut low_gap = make_test_gap(GapCriticality::Low, 0, 0.0, 0);
258        let mut high_gap = make_test_gap(GapCriticality::High, 0, 0.0, 0);
259        let mut medium_gap = make_test_gap(GapCriticality::Medium, 0, 0.0, 0);
260
261        // Compute scores so queue ordering works correctly
262        low_gap.score = compute_gap_score(&low_gap, &config);
263        high_gap.score = compute_gap_score(&high_gap, &config);
264        medium_gap.score = compute_gap_score(&medium_gap, &config);
265
266        let mut queue = PriorityQueue::new();
267        queue.push(low_gap);
268        queue.push(high_gap);
269        queue.push(medium_gap);
270
271        let first = queue.pop().unwrap();
272        assert_eq!(first.criticality, GapCriticality::High);
273
274        let second = queue.pop().unwrap();
275        assert_eq!(second.criticality, GapCriticality::Medium);
276
277        let third = queue.pop().unwrap();
278        assert_eq!(third.criticality, GapCriticality::Low);
279    }
280
281    #[test]
282    fn test_priority_queue_tiebreaker_by_age() {
283        let mut queue = PriorityQueue::new();
284        let config = ScoringConfig::default();
285
286        // Same criticality and depth
287        let new_gap = make_test_gap(GapCriticality::Medium, 0, 0.0, 0);
288        let old_gap = make_test_gap(GapCriticality::Medium, 0, 0.0, 10);
289
290        // Set same score explicitly to test tiebreaker
291        let mut new_gap = new_gap;
292        let mut old_gap = old_gap;
293        let score = compute_gap_score(&new_gap, &config);
294        new_gap.score = score;
295        old_gap.score = score;
296
297        // Save created_at before moving into queue
298        let old_created_at = old_gap.created_at;
299
300        queue.push(old_gap);
301        queue.push(new_gap);
302
303        // Older gap should come first despite being pushed second
304        let first = queue.pop().unwrap();
305        assert_eq!(first.created_at, old_created_at);
306    }
307
308    #[test]
309    fn test_score_always_in_range() {
310        let config = ScoringConfig::default();
311
312        // Test extreme cases
313        let max_gap = make_test_gap(GapCriticality::High, 100, -1.0, 1000);
314        let min_gap = make_test_gap(GapCriticality::Low, 0, 1.0, 0);
315
316        let score_max = compute_gap_score(&max_gap, &config);
317        let score_min = compute_gap_score(&min_gap, &config);
318
319        assert!(score_max >= 0.0 && score_max <= 1.0);
320        assert!(score_min >= 0.0 && score_min <= 1.0);
321    }
322
323    #[test]
324    fn test_recompute_all_scores() {
325        let mut gaps = std::collections::HashMap::new();
326        let config = ScoringConfig::default();
327
328        let id1 = GapId::new();
329        let id2 = GapId::new();
330
331        gaps.insert(id1, make_test_gap(GapCriticality::High, 0, 0.0, 0));
332        gaps.insert(id2, make_test_gap(GapCriticality::Low, 0, 0.0, 0));
333
334        // Set wrong scores
335        for gap in gaps.values_mut() {
336            gap.score = 0.5;
337        }
338
339        // Recompute
340        recompute_all_scores(&mut gaps, &config);
341
342        // Scores should now differ
343        let score1 = gaps.get(&id1).unwrap().score;
344        let score2 = gaps.get(&id2).unwrap().score;
345
346        assert_ne!(score1, score2);
347        assert!(score1 > score2); // High > Low
348    }
349}