1use scc_api::{RankFeatures, RankItem, RankRequest, RankResult};
12
13pub struct Ranker<'a> {
15 engine: &'a crate::workspace::Engine<'a>,
16}
17
18impl<'a> Ranker<'a> {
20 pub fn new(engine: &'a crate::workspace::Engine<'a>) -> Self { Ranker { engine } }
22
23 fn ctx(&self) -> scc_context::ContextCompiler<'_> { self.engine.ctx() }
25
26 pub fn pagerank_global(&self) -> crate::Result<Vec<(String, f64)>> {
30 self.pagerank_global_with(&[])
31 }
32
33 pub fn pagerank_global_with(
36 &self,
37 contributors: &[EdgeWeightFn],
38 ) -> crate::Result<Vec<(String, f64)>> {
39 let ctx = self.ctx();
40 let owned: Vec<EdgeWeightFn> = contributors.iter().cloned().collect();
41 let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust(
42 &ctx.view,
43 move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
44 );
45 let v = ranker.global_vector();
46 Ok(ranker.nodes().iter().cloned().zip(v).collect())
47 }
48
49 pub fn pagerank_task(&self, goal: &str) -> crate::Result<Vec<(String, f64)>> {
52 self.pagerank_task_with(goal, &[])
53 }
54
55 pub fn pagerank_task_with(
58 &self,
59 goal: &str,
60 contributors: &[EdgeWeightFn],
61 ) -> crate::Result<Vec<(String, f64)>> {
62 let ctx = self.ctx();
63 let owned: Vec<EdgeWeightFn> = contributors.iter().cloned().collect();
64 let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust(
65 &ctx.view,
66 move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
67 );
68 let seeds = lexical_seeds(&ctx, goal);
69 let v = ranker.task_vector(&seeds);
70 Ok(ranker.nodes().iter().cloned().zip(v).collect())
71 }
72
73 fn fold_edge_contributors(
78 contributors: &[EdgeWeightFn],
79 subject: &str,
80 predicate: &str,
81 object: &str,
82 base: f64,
83 ) -> (Option<(String, f64)>, Vec<String>) {
84 let mut acc: Option<(String, f64)> = None;
85 let mut applied = Vec::new();
86 for c in contributors {
87 let current = match &acc {
89 None => base,
90 Some((m, v)) => apply_edge_weight(base, m, *v),
91 };
92 if let Some((mode, value)) = c(subject, predicate, object, current) {
93 match mode.as_str() {
94 "add" | "multiply" | "replace" | "veto" => {
95 applied.push(mode.clone());
96 acc = Some((mode, value));
97 }
98 _ => {}
99 }
100 }
101 }
102 (acc, applied)
103 }
104
105 pub fn candidates(&self, goal: &str, limit: usize) -> crate::Result<Vec<scc_context::rank::ScoredEntity>> {
108 self.candidates_with(goal, limit, &RankHooks::default())
109 }
110
111 pub fn candidates_with(
116 &self,
117 goal: &str,
118 limit: usize,
119 hooks: &RankHooks,
120 ) -> crate::Result<Vec<scc_context::rank::ScoredEntity>> {
121 let ctx = self.ctx();
122 let mut merged: std::collections::BTreeMap<String, scc_context::rank::ScoredEntity> =
123 scc_context::rank::collect_lexical_candidates(ctx.store, &ctx.view, goal, &[], limit.max(1))
124 .into_iter().map(|c| (c.id.clone(), c)).collect();
125 for prov in &hooks.candidates {
126 for mut c in prov(goal) {
127 if c.id.is_empty() {
128 continue;
129 }
130 c.reason = if c.reason.is_empty() { "plugin-candidate".into() } else { c.reason };
131 match merged.get(&c.id) {
132 Some(prev) if prev.score >= c.score => {}
133 _ => { merged.insert(c.id.clone(), c); }
134 }
135 }
136 }
137 let mut out: Vec<scc_context::rank::ScoredEntity> = merged.into_values().collect();
138 out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.id.cmp(&b.id)));
139 out.truncate(limit.max(1));
140 Ok(out)
141 }
142
143 pub fn symbols(&self, req: &RankRequest) -> crate::Result<RankResult> {
148 self.symbols_with_hooks(req, &RankHooks::default())
149 }
150
151 pub fn symbols_with_hooks(&self, req: &RankRequest, hooks: &RankHooks) -> crate::Result<RankResult> {
160 let profile_w: Option<BlendWeights> = match req.profile.as_deref() {
164 None | Some("default") => None,
165 Some(name) => Some(hooks.profiles.get(name).cloned().ok_or_else(|| {
166 let mut avail: Vec<&str> = hooks.profiles.keys().map(|s| s.as_str()).collect();
167 avail.insert(0, "default");
168 crate::EngineError::Other(format!(
169 "unknown ranking profile '{name}' (available: {})",
170 avail.join(", ")
171 ))
172 })?),
173 };
174 let profile_name: Option<&str> = req.profile.as_deref().filter(|p| *p != "default");
175 let ctx = self.ctx();
176 let goal = req.goal.as_deref().unwrap_or("");
177 let goal_terms = scc_context::rank::terms(goal);
178 let mut seeds = lexical_seeds(&ctx, goal);
179 for seed_fn in &hooks.seed_providers {
180 for s in seed_fn(goal) {
181 if let Some(e) = seeds.iter_mut().find(|x| x.id == s.id) { e.weight += s.weight; }
182 else { seeds.push(s); }
183 }
184 }
185 let seed_ids: std::collections::BTreeSet<&str> =
186 seeds.iter().map(|s| s.id.as_str()).collect();
187 let owned: Vec<EdgeWeightFn> = hooks.edge_weights.iter().cloned().collect();
188 let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust(
189 &ctx.view,
190 move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
191 );
192 let global_of: std::collections::BTreeMap<String, f64> =
193 ranker.project_to_symbols(&ranker.global_vector()).into_iter().collect();
194 let task_of: std::collections::BTreeMap<String, f64> =
195 ranker.project_to_symbols(&ranker.task_vector(&seeds)).into_iter().collect();
196 let has_task = !goal.is_empty();
197 let map = scc_context::surface::compile_surface_map(&ctx);
198 let required = scc_context::surface::required_ids(&map, &ctx);
199 let mut best: std::collections::BTreeMap<&str, RankItem> = std::collections::BTreeMap::new();
200 for e in &map.entries {
201 let task_ppr = task_of.get(&e.symbol_id).copied().unwrap_or(0.0);
202 let global_ppr = global_of.get(&e.symbol_id).copied().unwrap_or(0.0);
203 let lexical = scc_context::surface::entry_lexical(e, &goal_terms);
204 let confidence = e.confidence as f64;
205 let criticality = if seed_ids.contains(e.symbol_id.as_str()) || required.contains(&e.id) { 1.0 } else { importance_file_score(&e.path) };
206 let change_risk = if !e.path.is_empty() && ctx.stale_paths.iter().any(|p| p == &e.path) { 1.0 } else { 0.0 };
207 let blend = match profile_w.as_ref() {
208 None => scc_context::pagerank::final_importance(task_ppr, global_ppr, lexical, 0.0, confidence, criticality, change_risk, 0.0, has_task),
209 Some(w) => {
210 use scc_context::pagerank as pr;
211 let (tw, gw) = if has_task { (w.task_ppr.unwrap_or(pr::TASK_PPR_WEIGHT), w.global_ppr.unwrap_or(pr::GLOBAL_PPR_WEIGHT)) }
212 else { (w.task_ppr.unwrap_or(0.0), w.global_ppr.unwrap_or(pr::NO_TASK_GLOBAL_WEIGHT)) };
213 tw * task_ppr
214 + gw * global_ppr
215 + w.lexical.unwrap_or(pr::LEXICAL_WEIGHT) * lexical
216 + w.semantic.unwrap_or(pr::SEMANTIC_WEIGHT) * 0.0
217 + w.confidence.unwrap_or(pr::CONFIDENCE_WEIGHT) * confidence
218 + w.criticality.unwrap_or(pr::CRITICALITY_WEIGHT) * criticality
219 + w.change_risk.unwrap_or(pr::CHANGE_RISK_WEIGHT) * change_risk
220 + w.novelty.unwrap_or(pr::NOVELTY_WEIGHT) * 0.0
221 }
222 };
223 let scale = 1.0 / (1.0 - scc_context::pagerank::SEMANTIC_WEIGHT);
224 let novelty_w = profile_w.as_ref().and_then(|w| w.novelty).unwrap_or(scc_context::pagerank::NOVELTY_WEIGHT);
225 let total = blend * scale + novelty_w * 1.0;
226 let mut plugin_features = std::collections::BTreeMap::new();
227 let mut reasons: Vec<String> = Vec::new();
228 if seed_ids.contains(e.symbol_id.as_str()) { reasons.push("task-seed".into()); }
229 let mut total = total;
230 for feat in &hooks.features {
231 let v = feat(&e.symbol_id, goal);
232 plugin_features.insert(v.name.clone(), v.score);
233 total += v.weight * v.score;
234 if !v.reason.is_empty() { reasons.push(v.reason.clone()); }
235 }
236 if let Some(pname) = profile_name {
237 reasons.push(format!("profile:{pname}"));
238 }
239 let specificity = if e.exported { 1.15 } else { 1.0 };
240 let item = RankItem { id: e.symbol_id.clone(), rank: total, position: 0,
241 features: RankFeatures { task_ppr, global_ppr, lexical, semantic: 0.0,
242 confidence, criticality, change_risk, novelty: 1.0 },
243 specificity, reasons, plugin_features };
244 match best.get(&e.symbol_id.as_str()) {
245 Some(prev) if prev.rank >= total => {}
246 _ => { best.insert(&e.symbol_id, item); }
247 }
248 }
249 let mut items: Vec<RankItem> = best.into_values().collect();
250 if !hooks.edge_weights.is_empty() {
251 for it in items.iter_mut() {
252 it.reasons.push(format!("edge-weights({})", hooks.edge_weights.len()));
253 }
254 }
255 for r in &hooks.rerankers { r(&mut items, goal); }
256 items.sort_by(|a, b| b.rank.partial_cmp(&a.rank).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.id.cmp(&b.id)));
257 items.truncate(req.limit.max(1));
258 for (i, it) in items.iter_mut().enumerate() { it.position = i + 1; }
259 let mut warnings = Vec::new();
260 if !hooks.edge_weights.is_empty() {
261 warnings.push(format!(
262 "{} edge-weight contributor(s) applied to the rank graph",
263 hooks.edge_weights.len()
264 ));
265 }
266 Ok(RankResult { items, omitted_ids: Vec::new(), warnings })
267 }
268}
269
270fn lexical_seeds(ctx: &scc_context::ContextCompiler<'_>, goal: &str) -> Vec<scc_core::TaskSeed> {
272 if goal.is_empty() { return Vec::new(); }
273 scc_context::rank::collect_lexical_candidates(ctx.store, &ctx.view, goal, &[], 16)
274 .into_iter()
275 .map(|c| scc_core::TaskSeed { kind: c.kind, id: c.id, weight: c.score })
276 .collect()
277}
278
279
280fn importance_file_score(path: &str) -> f64 {
282 let base = path.rsplit('/').next().unwrap_or(path);
284 const IMPORTANT: &[&str] = &[
285 "package.json", "pnpm-workspace.yaml", "yarn.lock", "Cargo.toml",
286 "Cargo.lock", "go.mod", "pyproject.toml", "setup.py", "setup.cfg",
287 "requirements.txt", "pom.xml", "build.gradle", "build.gradle.kts",
288 "settings.gradle", "settings.gradle.kts", "gradlew", "Makefile",
289 "CMakeLists.txt", "mix.exs", "Gemfile", "composer.json",
290 "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
291 "compose.yml", "compose.yaml", ".dockerignore",
292 ".github/workflows/ci.yml", ".github/workflows/main.yml",
293 ".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml",
294 ".circleci/config.yml", "buildkite.yml",
295 "main.py", "main.go", "main.ts", "index.ts", "index.js",
296 "app.py", "server.py", "server.ts", "server.js", "cli.py",
297 "cli.ts", "cli.go", "src/main.rs", "bin/main.rs", "app.js",
298 "app.ts",
299 ];
300 if IMPORTANT.contains(&base) || IMPORTANT.contains(&path) || path.starts_with(".github/workflows/") { 0.5 }
301 else { 0.0 }
302}
303
304pub struct RankFeatureValue {
306 pub name: String,
307 pub score: f64,
308 pub weight: f64,
309 pub reason: String,
310}
311
312pub type SeedProvider = Box<dyn Fn(&str) -> Vec<scc_core::TaskSeed> + Send + Sync>;
314pub type CandidateProvider =
316 Box<dyn Fn(&str) -> Vec<scc_context::rank::ScoredEntity> + Send + Sync>;
317pub type RankFeatureFn = Box<dyn Fn(&str, &str) -> RankFeatureValue + Send + Sync>;
319pub type RerankerFn = Box<dyn Fn(&mut Vec<scc_api::RankItem>, &str) + Send + Sync>;
321pub type EdgeWeightFn = std::sync::Arc<
327 dyn for<'a, 'b, 'c> Fn(&'a str, &'b str, &'c str, f64) -> Option<(String, f64)> + Send + Sync,
328>;
329#[derive(Clone, Debug, Default)]
331pub struct BlendWeights {
333 pub task_ppr: Option<f64>,
334 pub global_ppr: Option<f64>,
335 pub lexical: Option<f64>,
336 pub semantic: Option<f64>,
337 pub confidence: Option<f64>,
338 pub criticality: Option<f64>,
339 pub change_risk: Option<f64>,
340 pub novelty: Option<f64>,
341}
342
343pub type SimilarityFn = std::sync::Arc<dyn Fn(&str, &str, Option<&str>, Option<&str>) -> f64 + Send + Sync>;
345
346#[derive(Default)]
347pub struct RankHooks {
349 pub seed_providers: Vec<SeedProvider>,
350 pub features: Vec<RankFeatureFn>,
351 pub rerankers: Vec<RerankerFn>,
352 pub edge_weights: Vec<EdgeWeightFn>,
353 pub similarities: Vec<SimilarityFn>,
357 pub candidates: Vec<CandidateProvider>,
360 pub profiles: std::collections::BTreeMap<String, BlendWeights>,
366}
367
368pub fn apply_edge_weight(base: f64, mode: &str, value: f64) -> f64 {
370 match mode {
371 "add" => base + value,
372 "multiply" => base * value,
373 "replace" => value,
374 "veto" => 0.0,
375 _ => base,
376 }
377}
378
379
380pub fn mmr_select(ranked: &[(String, f64)], similar: &dyn Fn(&str, &str) -> f64, lambda: f64, budget: usize) -> Vec<String> {
382 scc_context::selector::mmr_diversify(ranked, similar, lambda, budget.max(1))
383}
384
385pub fn default_similarity(a_group: Option<&str>, b_group: Option<&str>) -> f64 {
389 match (a_group, b_group) {
390 (Some(g1), Some(g2)) if !g1.is_empty() && g1 == g2 => 1.0,
391 _ => 0.0,
392 }
393}
394
395pub fn fold_similarity(
399 providers: &[SimilarityFn],
400 a: &str,
401 b: &str,
402 a_group: Option<&str>,
403 b_group: Option<&str>,
404) -> f64 {
405 for p in providers {
406 let v = p(a, b, a_group, b_group);
407 if v != 0.0 {
408 return v.clamp(0.0, 1.0);
409 }
410 }
411 default_similarity(a_group, b_group)
412}
413
414pub fn apply_quotas(items: &[(String, String, f64, usize)], quotas: &[(String, f64)], available_tokens: usize) -> Vec<String> {
416 use std::collections::HashMap;
421 let mut caps: HashMap<&str, usize> = HashMap::new();
422 for (kind, frac) in quotas {
423 caps.insert(kind.as_str(), (frac.clamp(0.0, 1.0) * available_tokens as f64).round() as usize);
424 }
425 let mut spent: HashMap<&str, usize> = HashMap::new();
426 let mut kind_of: HashMap<&str, &str> = HashMap::new();
427 let mut cost_of: HashMap<&str, usize> = HashMap::new();
428 for (id, kind, _, cost) in items {
429 kind_of.insert(id.as_str(), kind.as_str());
430 cost_of.insert(id.as_str(), *cost);
431 }
432 let mut out = Vec::new();
433 let mut order: Vec<usize> = (0..items.len()).collect();
435 order.sort_by(|&a, &b| items[b].2.partial_cmp(&items[a].2).unwrap_or(std::cmp::Ordering::Equal).then_with(|| items[a].0.cmp(&items[b].0)));
436 for i in order {
437 let (id, _, _, cost) = &items[i];
438 let k = kind_of.get(id.as_str()).copied().unwrap_or("core");
439 let take = match caps.get(k) {
440 None => true,
441 Some(&cap) => {
442 let s = spent.entry(k).or_insert(0);
443 let next = s.saturating_add(*cost);
444 if next <= cap { *s = next; true } else { false }
445 }
446 };
447 if take { out.push(id.clone()); }
448 }
449 out
450}
451
452pub fn select_with_budget(items: &[scc_core::ContextItem], budget: usize, hard_max: usize) -> Vec<usize> {
454 scc_context::selector::select_with_budget(items, budget, hard_max)
455}