Skip to main content

objects/object/
suggestion_core.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure context suggestion scoring.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5
6use super::StalenessStatus;
7
8pub const SUGGESTION_WINDOW: usize = 24;
9pub const MEDIUM_SUGGESTION_THRESHOLD: u32 = 45;
10pub const HIGH_SUGGESTION_THRESHOLD: u32 = 70;
11pub const MAJOR_REWRITE_THRESHOLD_PCT: u32 = 50;
12
13const CHANGE_WEIGHT: u32 = 16;
14const DISTINCT_STATE_WEIGHT: u32 = 8;
15const DISTINCT_AGENT_WEIGHT: u32 = 10;
16const RECENCY_WEIGHT: u32 = 12;
17const STALE_WEIGHT: u32 = 18;
18const HAS_CONTEXT_PENALTY: u32 = 35;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ContextSuggestionTier {
22    Medium,
23    High,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct ContextSuggestion {
28    pub path: String,
29    pub score: u32,
30    pub tier: ContextSuggestionTier,
31    pub reasons: Vec<String>,
32    pub recent_changes: u32,
33    pub distinct_states: u32,
34    pub distinct_agents: u32,
35    pub has_context: bool,
36    pub stale_annotations: u32,
37}
38
39#[derive(Default)]
40pub struct SuggestionSignal {
41    pub recent_changes: u32,
42    pub distinct_states: BTreeSet<String>,
43    pub distinct_agents: BTreeSet<String>,
44    pub latest_seen_index: Option<usize>,
45}
46
47pub struct SuggestionInputs {
48    pub signals: BTreeMap<String, SuggestionSignal>,
49    pub stale_map: HashMap<String, StalenessStatus>,
50    pub active_paths: BTreeSet<String>,
51    pub history_len: usize,
52}
53
54pub fn score_suggestions(inputs: SuggestionInputs, limit: usize) -> Vec<ContextSuggestion> {
55    let SuggestionInputs {
56        signals,
57        stale_map,
58        active_paths,
59        history_len,
60    } = inputs;
61    let mut suggestions = Vec::new();
62
63    for (path, signal) in signals {
64        let has_context = active_paths.contains(&path);
65        let stale_annotations = stale_map
66            .iter()
67            .filter(|(key, status)| {
68                key.starts_with(&format!("{path}:"))
69                    && !matches!(status, StalenessStatus::Fresh)
70            })
71            .count() as u32;
72
73        let mut score = signal.recent_changes.saturating_mul(CHANGE_WEIGHT);
74        score += (signal.distinct_states.len() as u32).saturating_mul(DISTINCT_STATE_WEIGHT);
75        score += (signal.distinct_agents.len() as u32).saturating_mul(DISTINCT_AGENT_WEIGHT);
76        if signal.latest_seen_index.unwrap_or(usize::MAX) <= 3 {
77            score += RECENCY_WEIGHT;
78        }
79        if stale_annotations > 0 {
80            score += stale_annotations.saturating_mul(STALE_WEIGHT);
81        }
82        if has_context && stale_annotations == 0 {
83            score = score.saturating_sub(HAS_CONTEXT_PENALTY);
84        }
85
86        let tier = if score >= HIGH_SUGGESTION_THRESHOLD {
87            Some(ContextSuggestionTier::High)
88        } else if score >= MEDIUM_SUGGESTION_THRESHOLD {
89            Some(ContextSuggestionTier::Medium)
90        } else {
91            None
92        };
93
94        let Some(tier) = tier else {
95            continue;
96        };
97
98        let mut reasons = Vec::new();
99        if signal.recent_changes >= 3 {
100            reasons.push(format!(
101                "{} recent changes across the last {} states",
102                signal.recent_changes, history_len
103            ));
104        }
105        if signal.distinct_agents.len() >= 2 {
106            reasons.push(format!(
107                "{} distinct agents touched this file",
108                signal.distinct_agents.len()
109            ));
110        }
111        if stale_annotations > 0 {
112            reasons.push(format!("{stale_annotations} annotation(s) may be stale"));
113        }
114        if !has_context {
115            reasons.push("no active file guidance exists yet".to_string());
116        }
117
118        suggestions.push(ContextSuggestion {
119            path,
120            score,
121            tier,
122            reasons,
123            recent_changes: signal.recent_changes,
124            distinct_states: signal.distinct_states.len() as u32,
125            distinct_agents: signal.distinct_agents.len() as u32,
126            has_context,
127            stale_annotations,
128        });
129    }
130
131    suggestions.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
132    suggestions.truncate(limit);
133    suggestions
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn score_suggestions_hits_tier_boundaries_and_context_penalty() {
142        let mut signals = BTreeMap::new();
143        signals.insert(
144            "high.rs".to_string(),
145            signal(2, [], ["anthropic/claude", "openai/codex"], None),
146        );
147        signals.insert(
148            "penalty.rs".to_string(),
149            signal(5, [], [], None),
150        );
151        signals.insert("low.rs".to_string(), signal(1, ["s1"], [], None));
152
153        let mut stale_map = HashMap::new();
154        stale_map.insert("high.rs:File".to_string(), StalenessStatus::Unknown);
155        stale_map.insert("penalty.rs:File".to_string(), StalenessStatus::Fresh);
156
157        let inputs = SuggestionInputs {
158            signals,
159            stale_map,
160            active_paths: BTreeSet::from(["penalty.rs".to_string()]),
161            history_len: 9,
162        };
163
164        let suggestions = score_suggestions(inputs, 10);
165
166        assert_eq!(
167            suggestions,
168            vec![
169                ContextSuggestion {
170                    path: "high.rs".to_string(),
171                    score: HIGH_SUGGESTION_THRESHOLD,
172                    tier: ContextSuggestionTier::High,
173                    reasons: vec![
174                        "2 distinct agents touched this file".to_string(),
175                        "1 annotation(s) may be stale".to_string(),
176                        "no active file guidance exists yet".to_string(),
177                    ],
178                    recent_changes: 2,
179                    distinct_states: 0,
180                    distinct_agents: 2,
181                    has_context: false,
182                    stale_annotations: 1,
183                },
184                ContextSuggestion {
185                    path: "penalty.rs".to_string(),
186                    score: MEDIUM_SUGGESTION_THRESHOLD,
187                    tier: ContextSuggestionTier::Medium,
188                    reasons: vec!["5 recent changes across the last 9 states".to_string()],
189                    recent_changes: 5,
190                    distinct_states: 0,
191                    distinct_agents: 0,
192                    has_context: true,
193                    stale_annotations: 0,
194                },
195            ]
196        );
197    }
198
199    fn signal<const S: usize, const A: usize>(
200        recent_changes: u32,
201        states: [&str; S],
202        agents: [&str; A],
203        latest_seen_index: Option<usize>,
204    ) -> SuggestionSignal {
205        SuggestionSignal {
206            recent_changes,
207            distinct_states: states.into_iter().map(str::to_string).collect(),
208            distinct_agents: agents.into_iter().map(str::to_string).collect(),
209            latest_seen_index,
210        }
211    }
212}