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        self.pagerank_global_with_hooks(contributors, &[])
40    }
41
42    /// Global vector with edge weights and extra rank-time edges applied.
43    // trace:exempt reason=internal-detail
44    pub fn pagerank_global_with_hooks(
45        &self,
46        contributors: &[EdgeWeightFn],
47        extra: &[(String, String, String, f64)],
48    ) -> crate::Result<Vec<(String, f64)>> {
49        let ctx = self.ctx();
50        let owned: Vec<EdgeWeightFn> = contributors.iter().cloned().collect();
51        let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust_and_extra(
52            &ctx.view,
53            move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
54            extra,
55        );
56        let v = ranker.global_vector();
57        Ok(ranker.nodes().iter().cloned().zip(v).collect())
58    }
59
60    /// Normalized reference graph (§123 intermediate): trusted
61    /// relationships mapped to reference kinds (call/read/write/…).
62    /// Read-only projection — the reference surface `SystemRanker`
63    /// diffuses over, with per-edge provenance and confidence.
64    // trace:exempt reason=internal-detail
65    pub fn reference_graph(&self) -> crate::Result<Vec<scc_core::ReferenceEdge>> {
66        let ctx = self.ctx();
67        Ok(scc_context::pagerank::build_reference_graph(&ctx.view))
68    }
69
70    /// Rank-universe nodes (§123 intermediate): (id, kind) over the
71    /// full heterogeneous universe in rank order. Same nodes every
72    /// vector is indexed by — the node table for pagerank.global/task.
73    // trace:exempt reason=internal-detail
74    pub fn universe(&self) -> crate::Result<Vec<(String, String)>> {
75        self.universe_with(&RankHooks::default(), "")
76    }
77
78    /// Rank universe merged with plugin rank-node providers (§124 item
79    /// 17): the same table `symbols_with_hooks` diffuses over. Read-only
80    /// stage view — no entity, relationship, or evidence is written.
81    // trace:exempt reason=internal-detail
82    pub fn universe_with(&self, hooks: &RankHooks, goal: &str) -> crate::Result<Vec<(String, String)>> {
83        let ctx = self.ctx();
84        let mut extra_nodes: Vec<(String, String)> = Vec::new();
85        for prov in &hooks.rank_nodes {
86            extra_nodes.extend(prov(goal));
87        }
88        let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust_extra_and_nodes(
89            &ctx.view,
90            |_s: &str, _p: &str, _o: &str, _b: f64| None,
91            &[],
92            &extra_nodes,
93        );
94        Ok(ranker.nodes().iter().cloned().zip(ranker.kinds().iter().cloned()).collect())
95    }
96
97    /// Project a universe vector to per-symbol scores (§123
98    /// intermediate): entity importance reaching owner/handler symbols.
99    /// `vector` is (id, score) pairs over universe ids (e.g. a
100    /// pagerank.global/task row); unknown ids score 0. Same projection
101    /// `symbols_with_hooks` blends from — exposed for debuggability.
102    // trace:exempt reason=internal-detail
103    pub fn project_symbols(&self, vector: &[(String, f64)]) -> crate::Result<Vec<(String, f64)>> {
104        let ctx = self.ctx();
105        let ranker = scc_context::pagerank::SystemRanker::new(&ctx.view);
106        let by_id: std::collections::BTreeMap<&str, f64> =
107            vector.iter().map(|(id, s)| (id.as_str(), *s)).collect();
108        let full: Vec<f64> = ranker.nodes().iter().map(|id| by_id.get(id.as_str()).copied().unwrap_or(0.0)).collect();
109        Ok(ranker.project_to_symbols(&full))
110    }
111
112    /// Raw rank-universe edges (§123.12): (subject, predicate, object,
113    /// base weight), pre-aggregation. No plugin hooks by contract — the
114    /// structure the vectors diffuse over.
115    // trace:exempt reason=internal-detail
116    pub fn rank_edges(&self) -> crate::Result<Vec<(String, String, String, f64)>> {
117        let ctx = self.ctx();
118        let ranker = scc_context::pagerank::SystemRanker::new(&ctx.view);
119        Ok(ranker.rank_edges())
120    }
121
122    /// Raw task-personalized PPR vector for goal.
123    // trace:exempt reason=internal-detail
124    pub fn pagerank_task(&self, goal: &str) -> crate::Result<Vec<(String, f64)>> {
125        self.pagerank_task_with(goal, &[])
126    }
127
128    /// Task vector with edge-weight contributors applied.
129    // trace:exempt reason=internal-detail
130    pub fn pagerank_task_with(
131        &self,
132        goal: &str,
133        contributors: &[EdgeWeightFn],
134    ) -> crate::Result<Vec<(String, f64)>> {
135        self.pagerank_task_with_hooks(goal, contributors, &[])
136    }
137
138    /// Task vector with edge weights and extra rank-time edges applied.
139    // trace:exempt reason=internal-detail
140    pub fn pagerank_task_with_hooks(
141        &self,
142        goal: &str,
143        contributors: &[EdgeWeightFn],
144        extra: &[(String, String, String, f64)],
145    ) -> crate::Result<Vec<(String, f64)>> {
146        let ctx = self.ctx();
147        let owned: Vec<EdgeWeightFn> = contributors.iter().cloned().collect();
148        let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust_and_extra(
149            &ctx.view,
150            move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
151            extra,
152        );
153        let seeds = lexical_seeds(&ctx, goal);
154        let v = ranker.task_vector(&seeds);
155        Ok(ranker.nodes().iter().cloned().zip(v).collect())
156    }
157
158    /// Fold chained edge-weight contributors over one base weight.
159    /// Returns (adjustment for the ranker, applied modes for reasons).
160    /// Unknown modes are no-change (never silent corruption).
161    // trace:exempt reason=internal-detail
162    fn fold_edge_contributors(
163        contributors: &[EdgeWeightFn],
164        subject: &str,
165        predicate: &str,
166        object: &str,
167        base: f64,
168    ) -> (Option<(String, f64)>, Vec<String>) {
169        let mut acc: Option<(String, f64)> = None;
170        let mut applied = Vec::new();
171        for c in contributors {
172            // Chain over the running value: re-resolve base through acc.
173            let current = match &acc {
174                None => base,
175                Some((m, v)) => apply_edge_weight(base, m, *v),
176            };
177            if let Some((mode, value)) = c(subject, predicate, object, current) {
178                match mode.as_str() {
179                    "add" | "multiply" | "replace" | "veto" => {
180                        applied.push(mode.clone());
181                        acc = Some((mode, value));
182                    }
183                    _ => {}
184                }
185            }
186        }
187        (acc, applied)
188    }
189
190    /// Engine required-coverage base set (entry ids `build_surface`
191    /// partitions on): invariant/invocation/flow/state-owner entries.
192    /// The op unions plugin coverage providers over this.
193    // trace:exempt reason=internal-detail
194    pub fn required_with(&self, goal: &str, hooks: &RankHooks) -> crate::Result<std::collections::BTreeSet<String>> {
195        let ctx = self.ctx();
196        let map = scc_context::surface::compile_surface_map(&ctx);
197        let mut required = scc_context::surface::required_ids(&map, &ctx);
198        for cov in &hooks.coverage {
199            for id in cov(goal) {
200                required.insert(id);
201            }
202        }
203        Ok(required)
204    }
205
206    /// Task-seed merge for goal (§123.11): lexical seeds + plugin
207    /// providers, weights summed by id. Shared by `ranking.seeds` and
208    /// `symbols_with_hooks` — one merge, two callers.
209    // trace:exempt reason=internal-detail
210    pub fn seeds_with(&self, goal: &str, hooks: &RankHooks) -> crate::Result<Vec<scc_core::TaskSeed>> {
211        let ctx = self.ctx();
212        let mut seeds = lexical_seeds(&ctx, goal);
213        for seed_fn in &hooks.seed_providers {
214            for x in seed_fn(goal) {
215                if let Some(e) = seeds.iter_mut().find(|s| s.id == x.id) { e.weight += x.weight; }
216                else { seeds.push(x); }
217            }
218        }
219        seeds.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.id.cmp(&b.id)));
220        Ok(seeds)
221    }
222
223    /// Lexical candidate generation for goal (stage 1).
224    // trace:exempt reason=internal-detail
225    pub fn candidates(&self, goal: &str, limit: usize) -> crate::Result<Vec<scc_context::rank::ScoredEntity>> {
226        self.candidates_with(goal, limit, &RankHooks::default())
227    }
228
229    /// Candidates with explicit plugin providers (one call, deterministic
230    /// order). Provider rows merge by canonical id: max score wins, the
231    /// provider reason is tagged `plugin:<id>` for explainability.
232    // trace:exempt reason=internal-detail
233    pub fn candidates_with(
234        &self,
235        goal: &str,
236        limit: usize,
237        hooks: &RankHooks,
238    ) -> crate::Result<Vec<scc_context::rank::ScoredEntity>> {
239        let ctx = self.ctx();
240        let mut merged: std::collections::BTreeMap<String, scc_context::rank::ScoredEntity> =
241            scc_context::rank::collect_lexical_candidates(ctx.store, &ctx.view, goal, &[], limit.max(1))
242                .into_iter().map(|c| (c.id.clone(), c)).collect();
243        for prov in &hooks.candidates {
244            for mut c in prov(goal) {
245                if c.id.is_empty() {
246                    continue;
247                }
248                c.reason = if c.reason.is_empty() { "plugin-candidate".into() } else { c.reason };
249                match merged.get(&c.id) {
250                    Some(prev) if prev.score >= c.score => {}
251                    _ => { merged.insert(c.id.clone(), c); }
252                }
253            }
254        }
255        let mut out: Vec<scc_context::rank::ScoredEntity> = merged.into_values().collect();
256        out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.id.cmp(&b.id)));
257        out.truncate(limit.max(1));
258        Ok(out)
259    }
260
261    /// Full task/global blend per symbol with feature decomposition.
262    /// Same math as build_surface (no MMR/quotas/budget — pure ranking).
263    /// Plugin seed/feature/rerank hooks apply here and are recorded.
264    // trace:v1 id=impl.scc-engine-ranking.symbols work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
265    pub fn symbols(&self, req: &RankRequest) -> crate::Result<RankResult> {
266        self.symbols_with_hooks(req, &RankHooks::default())
267    }
268
269    /// Full ranking trace (§19): the RankResult plus the inputs the
270    /// blend consumed — seed ids and required entry ids. Same
271    /// computation as symbols_with_hooks (no new math); the envelope
272    /// makes the decision auditable without re-deriving inputs.
273    // trace:exempt reason=internal-detail
274    pub fn trace_with_hooks(&self, req: &RankRequest, hooks: &RankHooks) -> crate::Result<(RankResult, Vec<String>, Vec<String>)> {
275        let goal = req.goal.as_deref().unwrap_or("");
276        let out = self.symbols_with_hooks(req, hooks)?;
277        let mut seeds: Vec<String> = self.seeds_with(goal, hooks)?.into_iter().map(|x| x.id).collect();
278        seeds.sort();
279        let mut required: Vec<String> = self.required_with(goal, hooks)?.into_iter().collect();
280        required.sort();
281        Ok((out, seeds, required))
282    }
283
284    /// symbols() with explicit plugin hooks (one call, deterministic order).
285    ///
286    /// Derivation shares the pipeline's inputs BY CONSTRUCTION: per-entry
287    /// confidence, required set, seed membership, and lexical scores come
288    /// from the compiled surface map and its helpers — the same values
289    /// `build_surface` blends. Per-symbol totals take the max over that
290    /// symbol's entries (overloads); blends are otherwise identical math.
291    // trace:v1 id=impl.scc-engine-ranking.symbols-hooks work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
292    pub fn symbols_with_hooks(&self, req: &RankRequest, hooks: &RankHooks) -> crate::Result<RankResult> {
293        // Named blend profiles (spec 12 + DoD 25): `default` plus any
294        // plugin-registered profile. Unknown names fail loudly — silently
295        // running default math under a requested profile would lie.
296        let profile_w: Option<BlendWeights> = match req.profile.as_deref() {
297            None | Some("default") => None,
298            Some(name) => Some(hooks.profiles.get(name).cloned().ok_or_else(|| {
299                let mut avail: Vec<&str> = hooks.profiles.keys().map(|s| s.as_str()).collect();
300                avail.insert(0, "default");
301                crate::EngineError::Other(format!(
302                    "unknown ranking profile '{name}' (available: {})",
303                    avail.join(", ")
304                ))
305            })?),
306        };
307        let profile_name: Option<&str> = req.profile.as_deref().filter(|p| *p != "default");
308        let ctx = self.ctx();
309        let goal = req.goal.as_deref().unwrap_or("");
310        let goal_terms = scc_context::rank::terms(goal);
311        let mut seeds = lexical_seeds(&ctx, goal);
312        for seed_fn in &hooks.seed_providers {
313            for s in seed_fn(goal) {
314                if let Some(e) = seeds.iter_mut().find(|x| x.id == s.id) { e.weight += s.weight; }
315                else { seeds.push(s); }
316            }
317        }
318        let seed_ids: std::collections::BTreeSet<&str> =
319            seeds.iter().map(|s| s.id.as_str()).collect();
320        let owned: Vec<EdgeWeightFn> = hooks.edge_weights.iter().cloned().collect();
321        // Extra rank-time edges (§48): collected per request, merged into
322        // diffusion, never into the canonical graph.
323        let mut extra: Vec<(String, String, String, f64)> = Vec::new();
324        for prov in &hooks.rank_edges {
325            extra.extend(prov(goal));
326        }
327        let extra_count = extra.len();
328        // Extra rank-universe nodes (§124 item 17): merged before edge
329        // indexing so provider edges can attach to provider nodes.
330        let mut extra_nodes: Vec<(String, String)> = Vec::new();
331        for prov in &hooks.rank_nodes {
332            extra_nodes.extend(prov(goal));
333        }
334        let node_count = extra_nodes.len();
335        let ranker = scc_context::pagerank::SystemRanker::with_edge_adjust_extra_and_nodes(
336            &ctx.view,
337            move |s, p, o, b| Self::fold_edge_contributors(&owned, s, p, o, b).0,
338            &extra,
339            &extra_nodes,
340        );
341        let global_of: std::collections::BTreeMap<String, f64> =
342            ranker.project_to_symbols(&ranker.global_vector()).into_iter().collect();
343        let task_of: std::collections::BTreeMap<String, f64> =
344            ranker.project_to_symbols(&ranker.task_vector(&seeds)).into_iter().collect();
345        let has_task = !goal.is_empty();
346        let map = scc_context::surface::compile_surface_map(&ctx);
347        let mut required = scc_context::surface::required_ids(&map, &ctx);
348        // Plugin coverage rules (§124 item 26): union contributed ids.
349        let mut required_by: usize = 0;
350        for cov in &hooks.coverage {
351            for id in cov(goal) {
352                if required.insert(id) {
353                    required_by += 1;
354                }
355            }
356        }
357        let mut best: std::collections::BTreeMap<&str, RankItem> = std::collections::BTreeMap::new();
358        for e in &map.entries {
359            let task_ppr = task_of.get(&e.symbol_id).copied().unwrap_or(0.0);
360            let global_ppr = global_of.get(&e.symbol_id).copied().unwrap_or(0.0);
361            let lexical = scc_context::surface::entry_lexical(e, &goal_terms);
362            let confidence = e.confidence as f64;
363            let default_criticality = if seed_ids.contains(e.symbol_id.as_str()) || required.contains(&e.id) { 1.0 } else { importance_file_score(&e.path) };
364            let (criticality, criticality_src) = resolve_override(&hooks.criticality, &e.symbol_id, goal, default_criticality);
365            let (novelty, novelty_src) = resolve_override(&hooks.novelty, &e.symbol_id, goal, 1.0);
366            let default_risk = if !e.path.is_empty() && ctx.stale_paths.iter().any(|p| p == &e.path) { 1.0 } else { 0.0 };
367            let (change_risk, risk_src) = resolve_override(&hooks.risk, &e.symbol_id, goal, default_risk);
368            let (semantic, semantic_src) = resolve_override(&hooks.semantic, &e.symbol_id, goal, 0.0);
369            let blend = match profile_w.as_ref() {
370                None => scc_context::pagerank::final_importance(task_ppr, global_ppr, lexical, semantic, confidence, criticality, change_risk, 0.0, has_task),
371                Some(w) => {
372                    use scc_context::pagerank as pr;
373                    let (tw, gw) = if has_task { (w.task_ppr.unwrap_or(pr::TASK_PPR_WEIGHT), w.global_ppr.unwrap_or(pr::GLOBAL_PPR_WEIGHT)) }
374                        else { (w.task_ppr.unwrap_or(0.0), w.global_ppr.unwrap_or(pr::NO_TASK_GLOBAL_WEIGHT)) };
375                    tw * task_ppr
376                        + gw * global_ppr
377                        + w.lexical.unwrap_or(pr::LEXICAL_WEIGHT) * lexical
378                        + w.semantic.unwrap_or(pr::SEMANTIC_WEIGHT) * semantic
379                        + w.confidence.unwrap_or(pr::CONFIDENCE_WEIGHT) * confidence
380                        + w.criticality.unwrap_or(pr::CRITICALITY_WEIGHT) * criticality
381                        + w.change_risk.unwrap_or(pr::CHANGE_RISK_WEIGHT) * change_risk
382                        + w.novelty.unwrap_or(pr::NOVELTY_WEIGHT) * 0.0
383                }
384            };
385            let scale = 1.0 / (1.0 - scc_context::pagerank::SEMANTIC_WEIGHT);
386            let novelty_w = profile_w.as_ref().and_then(|w| w.novelty).unwrap_or(scc_context::pagerank::NOVELTY_WEIGHT);
387            let total = blend * scale + novelty_w * novelty;
388            let mut plugin_features = std::collections::BTreeMap::new();
389            let mut reasons: Vec<String> = Vec::new();
390            if seed_ids.contains(e.symbol_id.as_str()) { reasons.push("task-seed".into()); }
391            if let Some(src) = criticality_src { reasons.push(format!("criticality:{src}")); }
392            if let Some(src) = novelty_src { reasons.push(format!("novelty:{src}")); }
393            if let Some(src) = risk_src { reasons.push(format!("risk:{src}")); }
394            if let Some(src) = semantic_src { reasons.push(format!("semantic:{src}")); }
395            if required_by > 0 && required.contains(&e.id) && !seed_ids.contains(e.symbol_id.as_str()) { reasons.push(format!("required-by:plugin({required_by})")); }
396            let mut total = total;
397            for feat in &hooks.features {
398                let v = feat(&e.symbol_id, goal);
399                plugin_features.insert(v.name.clone(), v.score);
400                total += v.weight * v.score;
401                if !v.reason.is_empty() { reasons.push(v.reason.clone()); }
402            }
403            if let Some(pname) = profile_name {
404                reasons.push(format!("profile:{pname}"));
405            }
406            let specificity = if e.exported { 1.15 } else { 1.0 };
407            let item = RankItem { id: e.symbol_id.clone(), rank: total, position: 0,
408                features: RankFeatures { task_ppr, global_ppr, lexical, semantic,
409                    confidence, criticality, change_risk, novelty },
410                specificity, reasons, plugin_features };
411            match best.get(&e.symbol_id.as_str()) {
412                Some(prev) if prev.rank >= total => {}
413                _ => { best.insert(&e.symbol_id, item); }
414            }
415        }
416        let mut items: Vec<RankItem> = best.into_values().collect();
417        if !hooks.edge_weights.is_empty() {
418            for it in items.iter_mut() {
419                it.reasons.push(format!("edge-weights({})", hooks.edge_weights.len()));
420            }
421        }
422        if extra_count > 0 {
423            for it in items.iter_mut() {
424                it.reasons.push(format!("rank-edges({extra_count})"));
425            }
426        }
427        if node_count > 0 {
428            for it in items.iter_mut() {
429                it.reasons.push(format!("rank-nodes({node_count})"));
430            }
431        }
432        for r in &hooks.rerankers { r(&mut items, goal); }
433        items.sort_by(|a, b| b.rank.partial_cmp(&a.rank).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.id.cmp(&b.id)));
434        items.truncate(req.limit.max(1));
435        for (i, it) in items.iter_mut().enumerate() { it.position = i + 1; }
436        let mut warnings = Vec::new();
437        if !hooks.edge_weights.is_empty() {
438            warnings.push(format!(
439                "{} edge-weight contributor(s) applied to the rank graph",
440                hooks.edge_weights.len()
441            ));
442        }
443        if extra_count > 0 {
444            warnings.push(format!(
445                "{extra_count} extra rank-time edge(s) merged into diffusion (never canonical)"
446            ));
447        }
448        if node_count > 0 {
449            warnings.push(format!(
450                "{node_count} extra rank-universe node(s) merged into diffusion (never canonical)"
451            ));
452        }
453        Ok(RankResult { items, omitted_ids: Vec::new(), warnings })
454    }
455}
456
457// trace:exempt reason=internal-detail
458fn lexical_seeds(ctx: &scc_context::ContextCompiler<'_>, goal: &str) -> Vec<scc_core::TaskSeed> {
459    if goal.is_empty() { return Vec::new(); }
460    scc_context::rank::collect_lexical_candidates(ctx.store, &ctx.view, goal, &[], 16)
461        .into_iter()
462        .map(|c| scc_core::TaskSeed { kind: c.kind, id: c.id, weight: c.score })
463        .collect()
464}
465
466
467// trace:exempt reason=internal-detail
468fn importance_file_score(path: &str) -> f64 {
469    // Mirrors scc-context file_importance (pub(crate) there).
470    let base = path.rsplit('/').next().unwrap_or(path);
471    const IMPORTANT: &[&str] = &[
472        "package.json", "pnpm-workspace.yaml", "yarn.lock", "Cargo.toml",
473        "Cargo.lock", "go.mod", "pyproject.toml", "setup.py", "setup.cfg",
474        "requirements.txt", "pom.xml", "build.gradle", "build.gradle.kts",
475        "settings.gradle", "settings.gradle.kts", "gradlew", "Makefile",
476        "CMakeLists.txt", "mix.exs", "Gemfile", "composer.json",
477        "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
478        "compose.yml", "compose.yaml", ".dockerignore",
479        ".github/workflows/ci.yml", ".github/workflows/main.yml",
480        ".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml",
481        ".circleci/config.yml", "buildkite.yml",
482        "main.py", "main.go", "main.ts", "index.ts", "index.js",
483        "app.py", "server.py", "server.ts", "server.js", "cli.py",
484        "cli.ts", "cli.go", "src/main.rs", "bin/main.rs", "app.js",
485        "app.ts",
486    ];
487    if IMPORTANT.contains(&base) || IMPORTANT.contains(&path) || path.starts_with(".github/workflows/") { 0.5 }
488    else { 0.0 }
489}
490
491// trace:exempt reason=internal-detail
492pub struct RankFeatureValue {
493    pub name: String,
494    pub score: f64,
495    pub weight: f64,
496    pub reason: String,
497}
498
499// trace:exempt reason=internal-detail
500pub type SeedProvider = Box<dyn Fn(&str) -> Vec<scc_core::TaskSeed> + Send + Sync>;
501// trace:exempt reason=internal-detail
502pub type CandidateProvider =
503    Box<dyn Fn(&str) -> Vec<scc_context::rank::ScoredEntity> + Send + Sync>;
504// trace:exempt reason=internal-detail
505pub type RankFeatureFn = Box<dyn Fn(&str, &str) -> RankFeatureValue + Send + Sync>;
506// trace:exempt reason=internal-detail
507pub type RerankerFn = Box<dyn Fn(&mut Vec<scc_api::RankItem>, &str) + Send + Sync>;
508/// Edge-weight contributor: per-edge (subject, predicate, object, base)
509/// adjustment. Return `Some((mode, value))` to alter the weight, `None`
510/// for no change. Modes: add | multiply | replace | veto. Every applied
511/// contribution is recorded on the affected rank items' reasons.
512// trace:exempt reason=internal-detail
513pub type EdgeWeightFn = std::sync::Arc<
514    dyn for<'a, 'b, 'c> Fn(&'a str, &'b str, &'c str, f64) -> Option<(String, f64)> + Send + Sync,
515>;
516/// Per-feature linear blend weights. `None` = SCC default.
517#[derive(Clone, Debug, Default)]
518// trace:exempt reason=internal-detail
519pub struct BlendWeights {
520    pub task_ppr: Option<f64>,
521    pub global_ppr: Option<f64>,
522    pub lexical: Option<f64>,
523    pub semantic: Option<f64>,
524    pub confidence: Option<f64>,
525    pub criticality: Option<f64>,
526    pub change_risk: Option<f64>,
527    pub novelty: Option<f64>,
528}
529
530/// Required-coverage contributor (§124 item 26): symbol ids that MUST
531/// count as required (criticality 1.0), unioned with the engine's
532/// `required_ids` base. E.g. a security plugin marks tainted sinks
533/// required so task ranking never demotes them.
534// trace:exempt reason=internal-detail
535pub type CoverageProvider =
536    Box<dyn Fn(&str) -> Vec<String> + Send + Sync>;
537
538/// Rank-time edge contributor (§48): `(subject, predicate, object,
539/// weight)` triples that enter diffusion without becoming canonical
540/// architecture facts. Every edge records its source (see the
541/// `rank-edges(N)` reason); endpoint ids outside the rank universe and
542/// non-positive/non-finite weights are skipped by the ranker.
543// trace:exempt reason=internal-detail
544pub type RankEdgeProvider =
545    Box<dyn Fn(&str) -> Vec<(String, String, String, f64)> + Send + Sync>;
546
547/// Rank-universe node contributor (§124 item 17 RankNodeProvider):
548/// `(id, kind)` pairs merged into the rank universe before edge indexing.
549/// Only rankable kinds enter (the ranker's RANKABLE_KINDS gate); empty ids,
550/// unknown kinds, and duplicates of view nodes abstain. Rank-time only:
551/// no entity, relationship, or evidence is written. Deterministic chain
552/// order; the universe stays id-sorted regardless of provider order.
553// trace:exempt reason=internal-detail
554pub type RankNodeProvider =
555    Box<dyn Fn(&str) -> Vec<(String, String)> + Send + Sync>;
556
557/// Criticality override (§53 CriticalityProvider): per-symbol criticality
558/// in [0,1], or `None` to keep the engine default (seed/required =>
559/// 1.0, else file-importance score). First `Some` in chain order wins;
560/// out-of-range values degrade to the default (never clamp silently into
561/// the blend — a contributor that cannot name a valid score abstains).
562// trace:exempt reason=internal-detail
563pub type CriticalityProvider =
564    Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
565
566/// Novelty override (§53 NoveltyProvider): per-symbol novelty in [0,1],
567/// or `None` to keep the engine default of 1.0. Same first-Some-wins and
568/// range discipline as [`CriticalityProvider`].
569// trace:exempt reason=internal-detail
570pub type NoveltyProvider =
571    Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
572
573/// Change-risk override (§53 RiskProvider): per-symbol change risk in
574/// [0,1], or `None` to keep the engine default (stale path => 1.0, else
575/// 0.0). Same first-Some-wins and range discipline as
576/// [`CriticalityProvider`] (shared [`ScalarOverrideProvider`] alias).
577// trace:exempt reason=internal-detail
578pub type RiskProvider = ScalarOverrideProvider;
579
580/// Semantic-score override (§53 SemanticProvider): per-symbol semantic
581/// relevance in [0,1] against the goal, or `None` to keep the engine
582/// default of 0.0 (no scorer configured). Same first-Some-wins and range
583/// discipline as [`CriticalityProvider`]. When any provider contributes,
584/// the blend keeps the documented redistribution math: the semantic
585/// share is real, so no renormalization applies.
586// trace:exempt reason=internal-detail
587pub type SemanticProvider = ScalarOverrideProvider;
588
589/// One scalar-override provider (criticality or novelty): `None` abstains.
590// trace:exempt reason=internal-detail
591pub type ScalarOverrideProvider =
592    Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
593
594// trace:exempt reason=internal-detail
595pub type SimilarityFn = std::sync::Arc<dyn Fn(&str, &str, Option<&str>, Option<&str>) -> f64 + Send + Sync>;
596
597#[derive(Default)]
598// trace:exempt reason=internal-detail
599pub struct RankHooks {
600    pub seed_providers: Vec<SeedProvider>,
601    pub features: Vec<RankFeatureFn>,
602    pub rerankers: Vec<RerankerFn>,
603    pub edge_weights: Vec<EdgeWeightFn>,
604    /// Pairwise item similarity for MMR diversification. First provider
605    /// returning a nonzero value wins (deterministic chain order);
606    /// the built-in default (same-group => 1.0) runs last.
607    pub similarities: Vec<SimilarityFn>,
608        /// Extra required-coverage providers (§124 item 26): contributed
609    /// symbol ids union with the engine `required_ids` base. Recorded in
610    /// reasons as `required-by:<n>`.
611    pub coverage: Vec<CoverageProvider>,
612/// Extra candidate providers (spec 18): merged with the lexical base
613    /// by canonical id, max score wins. Deterministic chain order.
614    pub candidates: Vec<CandidateProvider>,
615    /// Extra rank-time edges (§48): merged into diffusion, never into the
616    /// canonical graph. Deterministic chain order.
617    pub rank_edges: Vec<RankEdgeProvider>,
618    /// Extra rank-universe nodes (§124 item 17): merged before edge
619    /// indexing so provider edges can attach to provider nodes.
620    /// Deterministic chain order.
621    pub rank_nodes: Vec<RankNodeProvider>,
622    /// Criticality overrides (§53): first `Some` in chain order wins.
623    pub criticality: Vec<CriticalityProvider>,
624    /// Novelty overrides (§53): first `Some` in chain order wins.
625    pub novelty: Vec<NoveltyProvider>,
626    /// Change-risk overrides (§53): first `Some` in chain order wins.
627    pub risk: Vec<RiskProvider>,
628    /// Semantic-score overrides (§53): first `Some` in chain order wins.
629    pub semantic: Vec<SemanticProvider>,
630    /// Named blend profiles: profile name -> per-feature weight
631    /// overrides for the linear blend (feature keys: task_ppr,
632    /// global_ppr, lexical, semantic, confidence, criticality,
633    /// change_risk, novelty). Missing keys keep default weights.
634    /// Applied inside the same linear math; recorded in reasons.
635    pub profiles: std::collections::BTreeMap<String, BlendWeights>,
636}
637
638/// First-`Some`-wins override resolution: providers abstain with
639/// `None`; out-of-range values ([0,1] required) abstain too. Returns the
640/// value plus a `provider(N)` source tag for reasons, or `None` source
641/// when every provider abstained and the default stands.
642// trace:exempt reason=internal-detail
643pub fn resolve_override(
644    providers: &[ScalarOverrideProvider],
645    symbol: &str,
646    goal: &str,
647    default: f64,
648) -> (f64, Option<String>) {
649    for (i, prov) in providers.iter().enumerate() {
650        match prov(symbol, goal) {
651            Some(v) if v.is_finite() && (0.0..=1.0).contains(&v) => {
652                return (v, Some(format!("provider({i})")));
653            }
654            _ => {}
655        }
656    }
657    (default, None)
658}
659
660// trace:exempt reason=internal-detail
661pub fn apply_edge_weight(base: f64, mode: &str, value: f64) -> f64 {
662    match mode {
663        "add" => base + value,
664        "multiply" => base * value,
665        "replace" => value,
666        "veto" => 0.0,
667        _ => base,
668    }
669}
670
671
672/// One explicit feature row for [`score_entries`]: all eight core
673/// inputs plus the task-mode flag, keyed by id.
674// trace:exempt reason=internal-detail
675pub struct ScoreRow<'a> {
676    pub id: &'a str,
677    pub task_ppr: f64,
678    pub global_ppr: f64,
679    pub lexical: f64,
680    pub semantic: f64,
681    pub confidence: f64,
682    pub criticality: f64,
683    pub change_risk: f64,
684    pub novelty: f64,
685    pub has_task: bool,
686}
687
688/// Pure per-entry blend (§123 intermediate `ranking.score_entries`):
689/// `final_importance` over each explicit row. No store, no hooks — the
690/// same math `symbols_with_hooks` blends from, exposed for audit and
691/// for callers scoring their own feature rows.
692// trace:exempt reason=internal-detail
693pub fn score_entries(rows: &[ScoreRow<'_>]) -> Vec<(String, f64)> {
694    rows.iter().map(|r| (
695        r.id.to_string(),
696        scc_context::pagerank::final_importance(
697            r.task_ppr, r.global_ppr, r.lexical, r.semantic,
698            r.confidence, r.criticality, r.change_risk, r.novelty, r.has_task,
699        ),
700    )).collect()
701}
702
703// trace:exempt reason=internal-detail
704pub fn mmr_select(ranked: &[(String, f64)], similar: &dyn Fn(&str, &str) -> f64, lambda: f64, budget: usize) -> Vec<String> {
705    scc_context::selector::mmr_diversify(ranked, similar, lambda, budget.max(1))
706}
707
708/// Default MMR similarity: same non-empty group => 1.0, else 0.0.
709/// Groups are caller-supplied (component/path); `None`/empty never match.
710// trace:exempt reason=internal-detail
711pub fn default_similarity(a_group: Option<&str>, b_group: Option<&str>) -> f64 {
712    match (a_group, b_group) {
713        (Some(g1), Some(g2)) if !g1.is_empty() && g1 == g2 => 1.0,
714        _ => 0.0,
715    }
716}
717
718/// Group lookup for a ranked id (parallel groups vec).
719// trace:exempt reason=internal-detail
720pub fn group_of2<'a>(ranked: &[(String, f64)], groups: &'a [Option<String>], id: &str) -> Option<&'a str> {
721    ranked.iter().position(|(rid, _)| rid == id).and_then(|i| groups.get(i).and_then(|g| g.as_deref()))
722}
723
724/// Fold chained similarity providers over one pair: first nonzero wins.
725/// Falls back to [`default_similarity`] when no provider fires.
726// trace:exempt reason=internal-detail
727pub fn fold_similarity(
728    providers: &[SimilarityFn],
729    a: &str,
730    b: &str,
731    a_group: Option<&str>,
732    b_group: Option<&str>,
733) -> f64 {
734    for p in providers {
735        let v = p(a, b, a_group, b_group);
736        if v != 0.0 {
737            return v.clamp(0.0, 1.0);
738        }
739    }
740    default_similarity(a_group, b_group)
741}
742
743// trace:exempt reason=internal-detail
744pub fn apply_quotas(items: &[(String, String, f64, usize)], quotas: &[(String, f64)], available_tokens: usize) -> Vec<String> {
745    // Same contract as scc-context enforce_quotas (token-fraction caps,
746    // rank order preserved, unknown kinds uncapped): reimplemented over
747    // owned rows because that signature's `Fn(&str) -> &str` can only
748    // derive kinds from the id string itself, not external kind data.
749    use std::collections::HashMap;
750    let mut caps: HashMap<&str, usize> = HashMap::new();
751    for (kind, frac) in quotas {
752        caps.insert(kind.as_str(), (frac.clamp(0.0, 1.0) * available_tokens as f64).round() as usize);
753    }
754    let mut spent: HashMap<&str, usize> = HashMap::new();
755    let mut kind_of: HashMap<&str, &str> = HashMap::new();
756    let mut cost_of: HashMap<&str, usize> = HashMap::new();
757    for (id, kind, _, cost) in items {
758        kind_of.insert(id.as_str(), kind.as_str());
759        cost_of.insert(id.as_str(), *cost);
760    }
761    let mut out = Vec::new();
762    // Rank order: sort owned indices by value desc, id asc (stable).
763    let mut order: Vec<usize> = (0..items.len()).collect();
764    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)));
765    for i in order {
766        let (id, _, _, cost) = &items[i];
767        let k = kind_of.get(id.as_str()).copied().unwrap_or("core");
768        let take = match caps.get(k) {
769            None => true,
770            Some(&cap) => {
771                let s = spent.entry(k).or_insert(0);
772                let next = s.saturating_add(*cost);
773                if next <= cap { *s = next; true } else { false }
774            }
775        };
776        if take { out.push(id.clone()); }
777    }
778    out
779}
780
781// trace:exempt reason=internal-detail
782pub fn select_with_budget(items: &[scc_core::ContextItem], budget: usize, hard_max: usize) -> Vec<usize> {
783    scc_context::selector::select_with_budget(items, budget, hard_max)
784}