Skip to main content

fallow_api/similar_code/
mod.rs

1//! Local-provider orchestration for advisory similar-code discovery.
2
3use std::fmt::Write as _;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant};
6
7use fallow_engine::source::similar_code::SimilarCodeSourceDigest;
8use rustc_hash::FxHashMap;
9
10mod cache;
11mod protocol;
12mod transport;
13
14pub use protocol::SimilarCodeProviderStatus;
15
16const EMBED_BATCH_SIZE: usize = 1;
17const EMBED_RUN_TIMEOUT: Duration = Duration::from_mins(15);
18
19/// Classified local-provider failure used by the programmatic runtime.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) enum ProviderError {
22    /// The exact companion or its pinned model is not installed yet.
23    NotReady(String),
24    /// An installed companion failed execution, provenance, or protocol checks.
25    Failed(String),
26}
27
28impl ProviderError {
29    pub(crate) fn message(&self) -> &str {
30        match self {
31            Self::NotReady(message) | Self::Failed(message) => message,
32        }
33    }
34}
35
36/// Opaque, validated handle obtained before project source is read.
37pub(crate) struct ReadyProvider {
38    path: PathBuf,
39    pub(crate) status: SimilarCodeProviderStatus,
40}
41
42/// One transient source fragment selected for local embedding.
43pub(crate) struct EmbeddingInput<'a> {
44    pub(crate) source_sha256: SimilarCodeSourceDigest,
45    pub(crate) source: &'a str,
46}
47
48/// Privacy-safe cache and provider accounting for one embedding pass.
49pub(crate) struct EmbeddingResult {
50    /// Vectors in input order. Missing entries represent a bounded partial pass.
51    pub(crate) vectors: Vec<Option<Vec<f32>>>,
52    pub(crate) cache_hits: usize,
53    pub(crate) cache_misses: usize,
54    pub(crate) cache_writes: usize,
55    pub(crate) cache_invalid_entries: usize,
56    pub(crate) cache_disabled: bool,
57    pub(crate) cache_problem: Option<String>,
58    pub(crate) provider_problem: Option<String>,
59    pub(crate) inference_ms: f64,
60    pub(crate) truncated_functions: usize,
61}
62
63/// Provider-neutral validated batch used by the private orchestration seam.
64pub(crate) struct EmbeddingBatch {
65    pub(crate) vectors: Vec<EmbeddingBatchVector>,
66    pub(crate) inference_ms: f64,
67    pub(crate) problem: Option<String>,
68}
69
70/// One validated vector returned by a private provider session.
71pub(crate) struct EmbeddingBatchVector {
72    pub(crate) key: u32,
73    pub(crate) values: Vec<f32>,
74    pub(crate) truncated: bool,
75}
76
77/// Private injectable session used by production transport and hermetic tests.
78pub(crate) trait EmbeddingSession {
79    fn embed(&mut self, functions: &[(u32, &str)]) -> Result<EmbeddingBatch, String>;
80}
81
82/// Private lazy session factory. The provider is not started on an all-cache-hit run.
83pub(crate) trait EmbeddingSessionFactory {
84    fn spawn(&mut self) -> Result<Box<dyn EmbeddingSession>, String>;
85}
86
87struct LocalEmbeddingSession {
88    inner: transport::ProviderSession,
89}
90
91impl EmbeddingSession for LocalEmbeddingSession {
92    fn embed(&mut self, functions: &[(u32, &str)]) -> Result<EmbeddingBatch, String> {
93        let response = self.inner.embed(functions)?;
94        let problem = (response.status != protocol::EmbedCompletionStatus::Complete)
95            .then(|| transport::embed_problem(&response));
96        Ok(EmbeddingBatch {
97            vectors: response
98                .vectors
99                .into_iter()
100                .map(|vector| EmbeddingBatchVector {
101                    key: vector.key,
102                    values: vector.values,
103                    truncated: vector.truncated,
104                })
105                .collect(),
106            inference_ms: response.timing.inference_ms,
107            problem,
108        })
109    }
110}
111
112struct LocalEmbeddingSessionFactory<'a> {
113    path: &'a Path,
114}
115
116impl EmbeddingSessionFactory for LocalEmbeddingSessionFactory<'_> {
117    fn spawn(&mut self) -> Result<Box<dyn EmbeddingSession>, String> {
118        transport::ProviderSession::spawn(self.path)
119            .map(|inner| Box::new(LocalEmbeddingSession { inner }) as Box<dyn EmbeddingSession>)
120    }
121}
122
123struct EmbeddingMiss {
124    representative_index: usize,
125    occurrence_indices: Vec<usize>,
126}
127
128struct EmbeddingPlan {
129    vectors: Vec<Option<Vec<f32>>>,
130    misses: Vec<EmbeddingMiss>,
131    cache_hits: usize,
132    cache_misses: usize,
133    truncated_functions: usize,
134}
135
136/// Return the installed local provider status.
137///
138/// # Errors
139///
140/// Returns a structured message when the trusted sibling is missing, cannot be
141/// executed, or reports incompatible provenance.
142pub fn status() -> Result<SimilarCodeProviderStatus, String> {
143    let sidecar = transport::discover_provider()?;
144    let status = transport::provider_status(&sidecar)?;
145    validate_status(&status)?;
146    Ok(status)
147}
148
149/// Download and verify the pinned local model through the installed provider.
150///
151/// Callers must obtain explicit human confirmation before invoking this API.
152/// MCP, NAPI, project configuration, and agent workflows intentionally do not
153/// expose this mutation.
154///
155/// # Errors
156///
157/// Returns a structured message when setup fails or provenance is invalid.
158pub fn setup_local() -> Result<SimilarCodeProviderStatus, String> {
159    let sidecar = transport::discover_provider()?;
160    let status = transport::setup_provider(&sidecar)?;
161    validate_status(&status)?;
162    if !status.model_ready {
163        return Err("similar-code setup completed without a verified local model".to_owned());
164    }
165    Ok(status)
166}
167
168/// Immutable local provider identity used by public provenance output.
169#[must_use]
170pub fn provider_identity() -> (&'static str, &'static str, usize, &'static str) {
171    (
172        protocol::MODEL_ID,
173        protocol::MODEL_REVISION,
174        protocol::MODEL_DIMENSIONS,
175        protocol::MODEL_LICENSE,
176    )
177}
178
179/// Total bytes downloaded by an explicit local model setup.
180#[must_use]
181pub fn model_download_bytes() -> u64 {
182    protocol::MODEL_ARTIFACTS
183        .iter()
184        .map(|artifact| artifact.size)
185        .sum()
186}
187
188/// Resolve and validate the exact local companion before reading project source.
189pub(crate) fn ready_provider() -> Result<ReadyProvider, ProviderError> {
190    let path = transport::discover_provider().map_err(ProviderError::NotReady)?;
191    let status = transport::provider_status(&path).map_err(ProviderError::Failed)?;
192    validate_status(&status).map_err(ProviderError::Failed)?;
193    if !status.model_ready {
194        let problem = status.problem.unwrap_or_else(|| {
195            "the pinned local model is not installed; run `fallow similar-code setup --local`"
196                .to_owned()
197        });
198        return Err(ProviderError::NotReady(problem));
199    }
200    Ok(ReadyProvider { path, status })
201}
202
203/// Validate an exact companion path already resolved and signature-verified
204/// by an official distribution adapter.
205///
206/// This exists for in-process hosts such as Node, whose process executable is
207/// not the Fallow binary and therefore has no meaningful sibling discovery.
208/// The path is never read from project config, PATH, or a model response.
209pub(crate) fn ready_provider_from_adapter_path(
210    path: &Path,
211) -> Result<ReadyProvider, ProviderError> {
212    if !path.is_file() {
213        return Err(ProviderError::NotReady(format!(
214            "verified similar-code companion is unavailable at {}",
215            path.display()
216        )));
217    }
218    let path = path.to_path_buf();
219    let status = transport::provider_status(&path).map_err(ProviderError::Failed)?;
220    validate_status(&status).map_err(ProviderError::Failed)?;
221    if !status.model_ready {
222        let problem = status.problem.unwrap_or_else(|| {
223            "the pinned local model is not installed; run `fallow similar-code setup --local`"
224                .to_owned()
225        });
226        return Err(ProviderError::NotReady(problem));
227    }
228    Ok(ReadyProvider { path, status })
229}
230
231/// Embed selected source fragments using the persistent source-digest cache.
232pub(crate) fn embed_selected(
233    provider: &ReadyProvider,
234    project_root: &Path,
235    no_cache: bool,
236    inputs: &[EmbeddingInput<'_>],
237) -> Result<EmbeddingResult, ProviderError> {
238    let mut factory = LocalEmbeddingSessionFactory {
239        path: &provider.path,
240    };
241    embed_selected_with_factory(
242        Path::new(&provider.status.cache_dir),
243        project_root,
244        no_cache,
245        inputs,
246        EMBED_RUN_TIMEOUT,
247        &mut factory,
248    )
249}
250
251/// Private injectable embedding seam used by production transport and crate tests.
252pub(crate) fn embed_selected_with_factory(
253    provider_cache_dir: &Path,
254    project_root: &Path,
255    no_cache: bool,
256    inputs: &[EmbeddingInput<'_>],
257    run_timeout: Duration,
258    factory: &mut dyn EmbeddingSessionFactory,
259) -> Result<EmbeddingResult, ProviderError> {
260    let mut cache = cache::VectorCache::load(provider_cache_dir, project_root, no_cache);
261    let cache_invalid_entries = usize::from(cache.load_state == cache::CacheLoadState::Corrupt);
262    let cache_disabled = cache.load_state == cache::CacheLoadState::Disabled;
263    let mut plan = prepare_embedding_plan(&mut cache, inputs);
264    let mut inference_ms = 0.0f64;
265    let mut provider_problem = None;
266    if !plan.misses.is_empty() {
267        let started = Instant::now();
268        let mut session = None;
269        let mut batch_start = 0usize;
270        while batch_start < plan.misses.len() {
271            if started.elapsed() >= run_timeout {
272                provider_problem =
273                    Some("similar-code embedding stopped at the bounded run limit".to_owned());
274                break;
275            }
276            let session = match session.as_mut() {
277                Some(session) => session,
278                None => session.insert(factory.spawn().map_err(ProviderError::Failed)?),
279            };
280            let batch_end = batch_start
281                .saturating_add(EMBED_BATCH_SIZE)
282                .min(plan.misses.len());
283            let request = (batch_start..batch_end)
284                .map(|group_index| {
285                    let miss = &plan.misses[group_index];
286                    let key = u32::try_from(group_index).map_err(|_| {
287                        ProviderError::Failed(
288                            "similar-code digest group exceeded protocol capacity".to_owned(),
289                        )
290                    })?;
291                    Ok((key, inputs[miss.representative_index].source))
292                })
293                .collect::<Result<Vec<_>, ProviderError>>()?;
294            match session.embed(&request) {
295                Ok(batch) => {
296                    validate_embedding_batch(&batch, &request).map_err(ProviderError::Failed)?;
297                    inference_ms += batch.inference_ms;
298                    if batch.problem.is_some() {
299                        provider_problem = batch.problem;
300                    }
301                    for vector in batch.vectors {
302                        let group_index = usize::try_from(vector.key).map_err(|_| {
303                            ProviderError::Failed(
304                                "similar-code provider returned an invalid digest-group key"
305                                    .to_owned(),
306                            )
307                        })?;
308                        apply_embedding_vector(
309                            &mut cache,
310                            inputs,
311                            &mut plan,
312                            group_index,
313                            &vector.values,
314                            vector.truncated,
315                        )?;
316                    }
317                }
318                Err(error) => {
319                    provider_problem = Some(error);
320                    break;
321                }
322            }
323            batch_start = batch_end;
324        }
325    }
326    let save = cache.save();
327
328    Ok(EmbeddingResult {
329        vectors: plan.vectors,
330        cache_hits: plan.cache_hits,
331        cache_misses: plan.cache_misses,
332        cache_writes: save.durable_writes,
333        cache_invalid_entries,
334        cache_disabled,
335        cache_problem: save.problem,
336        provider_problem,
337        inference_ms,
338        truncated_functions: plan.truncated_functions,
339    })
340}
341
342fn validate_embedding_batch(batch: &EmbeddingBatch, request: &[(u32, &str)]) -> Result<(), String> {
343    if !batch.inference_ms.is_finite() || batch.inference_ms < 0.0 {
344        return Err("similar-code provider returned invalid timing".to_owned());
345    }
346    let mut expected = request.iter().map(|(key, _)| *key).collect::<Vec<_>>();
347    let mut actual = batch
348        .vectors
349        .iter()
350        .map(|vector| vector.key)
351        .collect::<Vec<_>>();
352    expected.sort_unstable();
353    actual.sort_unstable();
354    if actual.windows(2).any(|pair| pair[0] == pair[1])
355        || actual
356            .iter()
357            .any(|key| expected.binary_search(key).is_err())
358        || batch.vectors.iter().any(|vector| {
359            vector.values.len() != protocol::MODEL_DIMENSIONS
360                || vector.values.iter().any(|value| !value.is_finite())
361                || vector.values.iter().all(|value| *value == 0.0)
362        })
363        || (batch.vectors.len() == request.len()) == batch.problem.is_some()
364    {
365        return Err("similar-code provider returned an invalid embedding batch".to_owned());
366    }
367    Ok(())
368}
369
370fn prepare_embedding_plan(
371    cache: &mut cache::VectorCache,
372    inputs: &[EmbeddingInput<'_>],
373) -> EmbeddingPlan {
374    let mut vectors = vec![None; inputs.len()];
375    let mut misses = Vec::<EmbeddingMiss>::new();
376    let mut miss_groups: FxHashMap<SimilarCodeSourceDigest, usize> = FxHashMap::default();
377    let mut cache_hits = 0usize;
378    let mut cache_misses = 0usize;
379    let mut truncated_functions = 0usize;
380    for (index, input) in inputs.iter().enumerate() {
381        if let Some(entry) = cache.get(&input.source_sha256) {
382            vectors[index] = Some(entry.values.clone());
383            cache_hits = cache_hits.saturating_add(1);
384            truncated_functions =
385                truncated_functions.saturating_add(usize::from(entry.token_truncated));
386            continue;
387        }
388
389        cache_misses = cache_misses.saturating_add(1);
390        if let Some(group_index) = miss_groups.get(&input.source_sha256).copied() {
391            misses[group_index].occurrence_indices.push(index);
392        } else {
393            let group_index = misses.len();
394            miss_groups.insert(input.source_sha256, group_index);
395            misses.push(EmbeddingMiss {
396                representative_index: index,
397                occurrence_indices: vec![index],
398            });
399        }
400    }
401    EmbeddingPlan {
402        vectors,
403        misses,
404        cache_hits,
405        cache_misses,
406        truncated_functions,
407    }
408}
409
410fn apply_embedding_vector(
411    cache: &mut cache::VectorCache,
412    inputs: &[EmbeddingInput<'_>],
413    plan: &mut EmbeddingPlan,
414    group_index: usize,
415    values: &[f32],
416    token_truncated: bool,
417) -> Result<(), ProviderError> {
418    let miss = plan.misses.get(group_index).ok_or_else(|| {
419        ProviderError::Failed(
420            "similar-code provider returned an unknown digest-group key".to_owned(),
421        )
422    })?;
423    let digest = inputs[miss.representative_index].source_sha256;
424    cache.insert(digest, values.to_owned(), token_truncated);
425    if token_truncated {
426        plan.truncated_functions = plan
427            .truncated_functions
428            .saturating_add(miss.occurrence_indices.len());
429    }
430    for occurrence_index in &miss.occurrence_indices {
431        plan.vectors[*occurrence_index] = Some(values.to_owned());
432    }
433    Ok(())
434}
435
436/// Remove the model-specific vector cache. Model artifacts remain installed.
437fn clear_vector_cache(provider_cache_dir: &Path, project_root: &Path) -> Result<bool, String> {
438    cache::clear(provider_cache_dir, project_root)
439}
440
441/// Remove only persisted similar-code vectors for one project.
442///
443/// The downloaded model is user-level state and is never removed here.
444///
445/// # Errors
446///
447/// Returns an error when config resolution or cache removal fails.
448pub fn clear_project_cache(
449    root: &Path,
450    config_path: Option<&Path>,
451    allow_remote_extends: bool,
452) -> Result<bool, String> {
453    let project = fallow_engine::project_config::config_for_project_with_load_options(
454        root,
455        config_path,
456        fallow_config::ConfigLoadOptions {
457            allow_remote_extends,
458        },
459    )
460    .map_err(|error| format!("failed to load config: {error}"))?;
461    let provider = status()?;
462    clear_vector_cache(Path::new(&provider.cache_dir), &project.config.root)
463}
464
465pub(crate) fn model_artifact_sha256() -> &'static str {
466    protocol::MODEL_ARTIFACTS
467        .first()
468        .map_or("", |artifact| artifact.sha256)
469}
470
471pub(crate) fn parameter_sha256() -> String {
472    let digest = cache::parameter_digest();
473    digest.iter().fold(
474        String::with_capacity(digest.len().saturating_mul(2)),
475        |mut output, byte| {
476            let _ = write!(output, "{byte:02x}");
477            output
478        },
479    )
480}
481
482pub(crate) const fn embedding_batch_size() -> usize {
483    EMBED_BATCH_SIZE
484}
485
486pub(crate) const fn embedding_semantics_version() -> u32 {
487    protocol::EMBEDDING_SEMANTICS_VERSION
488}
489
490fn validate_status(status: &SimilarCodeProviderStatus) -> Result<(), String> {
491    if status.protocol_version != protocol::WIRE_PROTOCOL_VERSION
492        || status.embedding_semantics_version != protocol::EMBEDDING_SEMANTICS_VERSION
493        || status.sidecar_version != env!("CARGO_PKG_VERSION")
494        || status.model_id != protocol::MODEL_ID
495        || status.model_revision != protocol::MODEL_REVISION
496        || status.dimensions != protocol::MODEL_DIMENSIONS
497        || status.max_tokens != protocol::MODEL_MAX_TOKENS
498        || status.license != protocol::MODEL_LICENSE
499        || status.download_bytes != model_download_bytes()
500        || !status.analysis_offline
501        || (status.model_ready && !status.integrity_verified)
502    {
503        return Err(
504            "similar-code companion provenance does not match this Fallow release".to_owned(),
505        );
506    }
507    Ok(())
508}
509
510#[cfg(test)]
511#[expect(
512    clippy::unwrap_used,
513    reason = "test fixture construction must fail immediately"
514)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn duplicate_digest_is_inferred_once_and_warm_truncation_is_occurrence_aware() {
520        let temp = tempfile::tempdir().unwrap();
521        let cache_root = temp.path().join("user-cache");
522        let provider_cache_dir = cache_root.join("models").join(protocol::MODEL_REVISION);
523        let project_root = temp.path().join("project");
524        std::fs::create_dir_all(&cache_root).unwrap();
525        std::fs::create_dir_all(&project_root).unwrap();
526        let digest = SimilarCodeSourceDigest::new([8; 32]);
527        let inputs = [
528            EmbeddingInput {
529                source_sha256: digest,
530                source: "function same() { return 1; }",
531            },
532            EmbeddingInput {
533                source_sha256: digest,
534                source: "function same() { return 1; }",
535            },
536        ];
537
538        let mut cache = cache::VectorCache::load(&provider_cache_dir, &project_root, false);
539        let mut cold = prepare_embedding_plan(&mut cache, &inputs);
540        assert_eq!(cold.cache_misses, 2);
541        assert_eq!(cold.misses.len(), 1);
542        assert_eq!(cold.misses[0].occurrence_indices, vec![0, 1]);
543        apply_embedding_vector(
544            &mut cache,
545            &inputs,
546            &mut cold,
547            0,
548            &[0.25; protocol::MODEL_DIMENSIONS],
549            true,
550        )
551        .unwrap();
552        assert_eq!(cold.truncated_functions, 2);
553        assert!(cold.vectors.iter().all(Option::is_some));
554        assert_eq!(cache.save().durable_writes, 1);
555
556        let mut cache = cache::VectorCache::load(&provider_cache_dir, &project_root, false);
557        let warm = prepare_embedding_plan(&mut cache, &inputs);
558        assert_eq!(warm.cache_hits, 2);
559        assert_eq!(warm.cache_misses, 0);
560        assert_eq!(warm.truncated_functions, 2);
561        assert!(warm.misses.is_empty());
562        assert!(warm.vectors.iter().all(Option::is_some));
563    }
564}