1use 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}