1use std::collections::BinaryHeap;
10use std::cmp::Ordering;
11
12use chrono::Utc;
13
14use super::{KnowledgeGap, GapCriticality, ScoringConfig, GapId};
15
16pub fn compute_gap_score(gap: &KnowledgeGap, config: &ScoringConfig) -> f64 {
27 let criticality_score = match gap.criticality {
29 GapCriticality::High => 1.0,
30 GapCriticality::Medium => 0.6,
31 GapCriticality::Low => 0.3,
32 };
33
34 let depth_score = (gap.depth as f64 / 10.0).min(1.0);
36
37 let evidence_score = 1.0 - gap.evidence_strength.abs().clamp(0.0, 1.0);
39
40 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 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#[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 match self.gap.score.partial_cmp(&other.gap.score) {
82 Some(Ordering::Equal) => {
83 other.gap.created_at.cmp(&self.gap.created_at)
85 }
86 Some(order) => order,
87 None => Ordering::Equal,
88 }
89 }
90}
91
92pub struct PriorityQueue {
96 inner: BinaryHeap<ReverseKnowledgeGap>,
97}
98
99impl PriorityQueue {
100 pub fn new() -> Self {
102 Self {
103 inner: BinaryHeap::new(),
104 }
105 }
106
107 pub fn push(&mut self, gap: KnowledgeGap) {
109 self.inner.push(ReverseKnowledgeGap { gap });
110 }
111
112 pub fn pop(&mut self) -> Option<KnowledgeGap> {
116 self.inner.pop().map(|r| r.gap)
117 }
118
119 pub fn peek(&self) -> Option<&KnowledgeGap> {
121 self.inner.peek().map(|r| &r.gap)
122 }
123
124 pub fn len(&self) -> usize {
126 self.inner.len()
127 }
128
129 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
141pub 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 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); }
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 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 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 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 let old_created_at = old_gap.created_at;
299
300 queue.push(old_gap);
301 queue.push(new_gap);
302
303 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 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 for gap in gaps.values_mut() {
336 gap.score = 0.5;
337 }
338
339 recompute_all_scores(&mut gaps, &config);
341
342 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); }
349}