Skip to main content

scc_engine/
inference.rs

1//! Engine inference rankers (SCC-071): `EmbeddingScorer` fusing stored
2//! entity embeddings via cosine similarity, `EngineReranker` calling a
3//! separate `/rerank` model, the remote-model policy, and the `rankers`
4//! constructor task-pack plumbing uses when `inference.enabled` is set.
5//!
6//! Single implementation: transports resolve scorer + reranker through
7//! ONE engine constructor — no transport reimplements embedding auth
8//! policy or fallback semantics.
9
10use scc_context::rank::{Reranker, ScoredEntity, SemanticScorer};
11use scc_indexer::embed::{cosine, rerank, EmbedConfig, EMBED_KINDS};
12use scc_store::Store;
13use std::collections::HashMap;
14
15// trace:v1 id=impl.scc-engine-inference work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
16/// Fuses stored entity embeddings with the embedded goal. Vectors are
17/// preloaded once per pack generation.
18// trace:exempt reason=internal-detail
19pub struct EmbeddingScorer {
20    goal_vector: Vec<f32>,
21    vectors: HashMap<String, Vec<f32>>,
22}
23
24// trace:exempt reason=internal-detail
25impl EmbeddingScorer {
26// trace:exempt reason=internal-detail
27    pub fn from_vectors(goal_vector: Vec<f32>, vectors: std::collections::HashMap<String, Vec<f32>>) -> Self {
28        EmbeddingScorer { goal_vector, vectors }
29    }
30// trace:exempt reason=internal-detail
31    pub fn new(goal: &str, cfg: &EmbedConfig, store: &Store) -> Result<EmbeddingScorer, String> {
32        let vectors = scc_indexer::embed::embed_texts(cfg, &[goal])?;
33        let goal_vector = vectors
34            .into_iter()
35            .next()
36            .ok_or_else(|| "embedding request returned no vector".to_string())?;
37        let mut map = HashMap::new();
38        for kind in EMBED_KINDS {
39            for e in store.entities_by_kind(kind).map_err(|e| e.to_string())? {
40                if let Ok(Some((v, _))) = store.get_embedding(&e.id) {
41                    map.insert(e.id, v);
42                }
43            }
44        }
45        Ok(EmbeddingScorer {
46            goal_vector,
47            vectors: map,
48        })
49    }
50}
51
52// trace:exempt reason=internal-detail
53impl SemanticScorer for EmbeddingScorer {
54// trace:exempt reason=internal-detail
55    fn score(&self, _goal: &str, entity: &scc_core::Entity) -> f64 {
56        match self.vectors.get(&entity.id) {
57            Some(v) => cosine(&self.goal_vector, v),
58            None => 0.0,
59        }
60    }
61}
62
63/// Second-stage reranker calling the configured `/rerank` model on the top
64/// candidates. Any failure is a no-op (graceful degradation).
65// trace:exempt reason=internal-detail
66pub struct EngineReranker {
67    cfg: EmbedConfig,
68}
69
70// trace:exempt reason=internal-detail
71impl EngineReranker {
72// trace:exempt reason=internal-detail
73    pub fn new(cfg: &EmbedConfig) -> EngineReranker {
74        EngineReranker { cfg: cfg.clone() }
75    }
76}
77
78// trace:exempt reason=internal-detail
79impl Reranker for EngineReranker {
80// trace:exempt reason=internal-detail
81    fn rerank(&self, goal: &str, candidates: &mut Vec<ScoredEntity>) {
82        if candidates.is_empty() || self.cfg.rerank_model.is_none() {
83            return;
84        }
85        let docs: Vec<String> = candidates
86            .iter()
87            .take(30)
88            .map(|c| format!("{} {}", c.kind, c.name))
89            .collect();
90        if let Ok(scores) = rerank(&self.cfg, goal, &docs) {
91            for (c, s) in candidates.iter_mut().take(30).zip(scores.iter()) {
92                // rerank dominates; the lexical residue keeps ties
93                // deterministic
94                c.score = c.score * 0.2 + s * 5.0;
95                c.reason = format!("{} + rerank", c.reason);
96            }
97            candidates.sort_by(|a, b| {
98                b.score
99                    .partial_cmp(&a.score)
100                    .unwrap_or(std::cmp::Ordering::Equal)
101            });
102        }
103        // Err: graceful degrade — keep lexical order
104    }
105}
106
107/// Remote-model policy (P0, docs/SECURITY.md): repository-derived content
108/// may leave the machine only when `inference.enabled` AND
109/// `security.allow_remote_models` are both true. Loopback providers need
110/// only `inference.enabled`. Fails closed.
111// trace:exempt reason=internal-detail
112pub fn remote_inference_allowed(config: &scc_indexer::Config) -> bool {
113    if !config.inference.enabled {
114        return false;
115    }
116    let cfg = EmbedConfig::from_config(&config.inference);
117    !cfg.is_remote() || config.security.allow_remote_models
118}
119
120/// Build the scorer/reranker when inference is enabled; any provider failure
121/// degrades to (None, None) so the lexical ranker is always the fallback.
122// trace:exempt reason=internal-detail
123pub fn rankers(
124    store: &Store,
125    config: &scc_indexer::Config,
126    goal: &str,
127) -> (Option<EmbeddingScorer>, Option<EngineReranker>) {
128    if !remote_inference_allowed(config) {
129        if config.inference.enabled {
130            eprintln!(
131                "scc: warning: remote inference blocked by security policy \
132                 (security.allow_remote_models is false); using lexical ranking only"
133            );
134        }
135        return (None, None);
136    }
137    let cfg = EmbedConfig::from_config(&config.inference);
138    let scorer = EmbeddingScorer::new(goal, &cfg, store).ok();
139    let reranker = if cfg.rerank_model.is_some() {
140        Some(EngineReranker::new(&cfg))
141    } else {
142        None
143    };
144    (scorer, reranker)
145}