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 self.pagerank_global_with_hooks(contributors, &[])
40 }
41
42 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 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 pub fn universe(&self) -> crate::Result<Vec<(String, String)>> {
75 self.universe_with(&RankHooks::default(), "")
76 }
77
78 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 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 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 pub fn pagerank_task(&self, goal: &str) -> crate::Result<Vec<(String, f64)>> {
125 self.pagerank_task_with(goal, &[])
126 }
127
128 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 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 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 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 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 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 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 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 pub fn symbols(&self, req: &RankRequest) -> crate::Result<RankResult> {
266 self.symbols_with_hooks(req, &RankHooks::default())
267 }
268
269 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 pub fn symbols_with_hooks(&self, req: &RankRequest, hooks: &RankHooks) -> crate::Result<RankResult> {
293 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 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 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 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
457fn 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
467fn importance_file_score(path: &str) -> f64 {
469 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
491pub struct RankFeatureValue {
493 pub name: String,
494 pub score: f64,
495 pub weight: f64,
496 pub reason: String,
497}
498
499pub type SeedProvider = Box<dyn Fn(&str) -> Vec<scc_core::TaskSeed> + Send + Sync>;
501pub type CandidateProvider =
503 Box<dyn Fn(&str) -> Vec<scc_context::rank::ScoredEntity> + Send + Sync>;
504pub type RankFeatureFn = Box<dyn Fn(&str, &str) -> RankFeatureValue + Send + Sync>;
506pub type RerankerFn = Box<dyn Fn(&mut Vec<scc_api::RankItem>, &str) + Send + Sync>;
508pub 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#[derive(Clone, Debug, Default)]
518pub 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
530pub type CoverageProvider =
536 Box<dyn Fn(&str) -> Vec<String> + Send + Sync>;
537
538pub type RankEdgeProvider =
545 Box<dyn Fn(&str) -> Vec<(String, String, String, f64)> + Send + Sync>;
546
547pub type RankNodeProvider =
555 Box<dyn Fn(&str) -> Vec<(String, String)> + Send + Sync>;
556
557pub type CriticalityProvider =
564 Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
565
566pub type NoveltyProvider =
571 Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
572
573pub type RiskProvider = ScalarOverrideProvider;
579
580pub type SemanticProvider = ScalarOverrideProvider;
588
589pub type ScalarOverrideProvider =
592 Box<dyn Fn(&str, &str) -> Option<f64> + Send + Sync>;
593
594pub type SimilarityFn = std::sync::Arc<dyn Fn(&str, &str, Option<&str>, Option<&str>) -> f64 + Send + Sync>;
596
597#[derive(Default)]
598pub 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 pub similarities: Vec<SimilarityFn>,
608 pub coverage: Vec<CoverageProvider>,
612pub candidates: Vec<CandidateProvider>,
615 pub rank_edges: Vec<RankEdgeProvider>,
618 pub rank_nodes: Vec<RankNodeProvider>,
622 pub criticality: Vec<CriticalityProvider>,
624 pub novelty: Vec<NoveltyProvider>,
626 pub risk: Vec<RiskProvider>,
628 pub semantic: Vec<SemanticProvider>,
630 pub profiles: std::collections::BTreeMap<String, BlendWeights>,
636}
637
638pub 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
660pub 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
672pub 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
688pub 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
703pub 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
708pub 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
718pub 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
724pub 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
743pub fn apply_quotas(items: &[(String, String, f64, usize)], quotas: &[(String, f64)], available_tokens: usize) -> Vec<String> {
745 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 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
781pub 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}