Skip to main content

scc_engine/
ranking.rs

1//! Engine ranking namespace: every ranker stage callable independently.
2//!
3//! Thin wrappers over the existing `scc-context` pipeline pieces — no
4//! algorithm changes. `ranking.symbols` runs the FULL blend (same math as
5//! `build_surface`) and returns per-item feature decomposition plus
6//! plugin contributions; stage ops expose the raw vectors.
7//! Plugin hooks (spec 18): seed providers, edge-weight contributors,
8//! rank features, and rerankers chain deterministically; every
9//! contribution is recorded in the explanation.
10
11use scc_api::{RankFeatures, RankItem, RankRequest, RankResult};
12
13// trace:exempt reason=internal-detail
14pub struct Ranker<'a> {
15    engine: &'a crate::workspace::Engine<'a>,
16}
17
18// trace:exempt reason=internal-detail
19impl<'a> Ranker<'a> {
20    // trace:exempt reason=internal-detail
21    pub fn new(engine: &'a crate::workspace::Engine<'a>) -> Self { Ranker { engine } }
22
23    // trace:exempt reason=internal-detail
24    fn ctx(&self) -> scc_context::ContextCompiler<'_> { self.engine.ctx() }
25
26    /// Raw global PageRank vector: (node_id, score) over the full
27    /// heterogeneous universe, id-sorted. No projection, no blending.
28    // trace:exempt reason=internal-detail
29    pub fn pagerank_global(&self) -> crate::Result<Vec<(String, f64)>> {
30        self.pagerank_global_with(&[])
31    }
32
33    /// Global vector with edge-weight contributors applied.
34    // trace:exempt reason=internal-detail
35    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    /// Raw task-personalized PPR vector for goal.
50    // trace:exempt reason=internal-detail
51    pub fn pagerank_task(&self, goal: &str) -> crate::Result<Vec<(String, f64)>> {
52        self.pagerank_task_with(goal, &[])
53    }
54
55    /// Task vector with edge-weight contributors applied.
56    // trace:exempt reason=internal-detail
57    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    /// Fold chained edge-weight contributors over one base weight.
74    /// Returns (adjustment for the ranker, applied modes for reasons).
75    /// Unknown modes are no-change (never silent corruption).
76    // trace:exempt reason=internal-detail
77    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            // Chain over the running value: re-resolve base through acc.
88            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    /// Lexical candidate generation for goal (stage 1).
106    // trace:exempt reason=internal-detail
107    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    /// Candidates with explicit plugin providers (one call, deterministic
112    /// order). Provider rows merge by canonical id: max score wins, the
113    /// provider reason is tagged `plugin:<id>` for explainability.
114    // trace:exempt reason=internal-detail
115    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    /// Full task/global blend per symbol with feature decomposition.
144    /// Same math as build_surface (no MMR/quotas/budget — pure ranking).
145    /// Plugin seed/feature/rerank hooks apply here and are recorded.
146    // trace:v1 id=impl.scc-engine-ranking.symbols work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
147    pub fn symbols(&self, req: &RankRequest) -> crate::Result<RankResult> {
148        self.symbols_with_hooks(req, &RankHooks::default())
149    }
150
151    /// symbols() with explicit plugin hooks (one call, deterministic order).
152    ///
153    /// Derivation shares the pipeline's inputs BY CONSTRUCTION: per-entry
154    /// confidence, required set, seed membership, and lexical scores come
155    /// from the compiled surface map and its helpers — the same values
156    /// `build_surface` blends. Per-symbol totals take the max over that
157    /// symbol's entries (overloads); blends are otherwise identical math.
158    // trace:v1 id=impl.scc-engine-ranking.symbols-hooks work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
159    pub fn symbols_with_hooks(&self, req: &RankRequest, hooks: &RankHooks) -> crate::Result<RankResult> {
160        // Named blend profiles (spec 12 + DoD 25): `default` plus any
161        // plugin-registered profile. Unknown names fail loudly — silently
162        // running default math under a requested profile would lie.
163        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
270// trace:exempt reason=internal-detail
271fn 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
280// trace:exempt reason=internal-detail
281fn importance_file_score(path: &str) -> f64 {
282    // Mirrors scc-context file_importance (pub(crate) there).
283    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
304// trace:exempt reason=internal-detail
305pub struct RankFeatureValue {
306    pub name: String,
307    pub score: f64,
308    pub weight: f64,
309    pub reason: String,
310}
311
312// trace:exempt reason=internal-detail
313pub type SeedProvider = Box<dyn Fn(&str) -> Vec<scc_core::TaskSeed> + Send + Sync>;
314// trace:exempt reason=internal-detail
315pub type CandidateProvider =
316    Box<dyn Fn(&str) -> Vec<scc_context::rank::ScoredEntity> + Send + Sync>;
317// trace:exempt reason=internal-detail
318pub type RankFeatureFn = Box<dyn Fn(&str, &str) -> RankFeatureValue + Send + Sync>;
319// trace:exempt reason=internal-detail
320pub type RerankerFn = Box<dyn Fn(&mut Vec<scc_api::RankItem>, &str) + Send + Sync>;
321/// Edge-weight contributor: per-edge (subject, predicate, object, base)
322/// adjustment. Return `Some((mode, value))` to alter the weight, `None`
323/// for no change. Modes: add | multiply | replace | veto. Every applied
324/// contribution is recorded on the affected rank items' reasons.
325// trace:exempt reason=internal-detail
326pub 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/// Per-feature linear blend weights. `None` = SCC default.
330#[derive(Clone, Debug, Default)]
331// trace:exempt reason=internal-detail
332pub 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
343// trace:exempt reason=internal-detail
344pub type SimilarityFn = std::sync::Arc<dyn Fn(&str, &str, Option<&str>, Option<&str>) -> f64 + Send + Sync>;
345
346#[derive(Default)]
347// trace:exempt reason=internal-detail
348pub 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    /// Pairwise item similarity for MMR diversification. First provider
354    /// returning a nonzero value wins (deterministic chain order);
355    /// the built-in default (same-group => 1.0) runs last.
356    pub similarities: Vec<SimilarityFn>,
357    /// Extra candidate providers (spec 18): merged with the lexical base
358    /// by canonical id, max score wins. Deterministic chain order.
359    pub candidates: Vec<CandidateProvider>,
360    /// Named blend profiles: profile name -> per-feature weight
361    /// overrides for the linear blend (feature keys: task_ppr,
362    /// global_ppr, lexical, semantic, confidence, criticality,
363    /// change_risk, novelty). Missing keys keep default weights.
364    /// Applied inside the same linear math; recorded in reasons.
365    pub profiles: std::collections::BTreeMap<String, BlendWeights>,
366}
367
368// trace:exempt reason=internal-detail
369pub 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
380// trace:exempt reason=internal-detail
381pub 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
385/// Default MMR similarity: same non-empty group => 1.0, else 0.0.
386/// Groups are caller-supplied (component/path); `None`/empty never match.
387// trace:exempt reason=internal-detail
388pub 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
395/// Fold chained similarity providers over one pair: first nonzero wins.
396/// Falls back to [`default_similarity`] when no provider fires.
397// trace:exempt reason=internal-detail
398pub 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
414// trace:exempt reason=internal-detail
415pub fn apply_quotas(items: &[(String, String, f64, usize)], quotas: &[(String, f64)], available_tokens: usize) -> Vec<String> {
416    // Same contract as scc-context enforce_quotas (token-fraction caps,
417    // rank order preserved, unknown kinds uncapped): reimplemented over
418    // owned rows because that signature's `Fn(&str) -> &str` can only
419    // derive kinds from the id string itself, not external kind data.
420    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    // Rank order: sort owned indices by value desc, id asc (stable).
434    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
452// trace:exempt reason=internal-detail
453pub 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}