Skip to main content

spec_drift/llm/
mod.rs

1//! LLM-backed rule support.
2//!
3//! Everything in this module is strictly opt-in: the default [`Config::llm`]
4//! has `enabled = false`, and `--no-llm` on the CLI forces the null client
5//! regardless of config. Analyzers that consume an [`LlmClient`] receive a
6//! [`NullLlmClient`] in every default code path.
7//!
8//! # Design
9//!
10//! - The trait returns `Option<LlmVerdict>`. `None` is the universal
11//!   "no answer available" signal — used for: LLM disabled, budget exhausted,
12//!   network error, timeout, malformed response. Analyzers must treat `None`
13//!   as "skip silently, don't emit drift" because the rule is experimental.
14//! - The budget is enforced via an atomic counter on the wrapper. Analyzers
15//!   can't accidentally overspend even under `rayon` parallelism.
16
17pub mod anthropic;
18pub mod null;
19
20use crate::config::{LlmConfig, LlmProvider};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU32, Ordering};
23
24pub use anthropic::AnthropicLlmClient;
25pub use null::NullLlmClient;
26
27/// Verdict an LLM returns when asked whether a spec claim still matches code.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LlmVerdict {
30    /// True when the spec text still accurately describes the code.
31    pub match_spec: bool,
32    /// Short human-readable explanation — shown in the divergence report.
33    pub reason: String,
34}
35
36/// Minimum surface every LLM provider must implement.
37pub trait LlmClient: Send + Sync {
38    /// Ask the model whether `user_prompt` holds given the project context in
39    /// `system_prompt`. Returns `None` on any failure mode — analyzers treat
40    /// `None` as "skip, don't flag."
41    fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict>;
42}
43
44/// Budget + kill-switch wrapper. Every real LLM call flows through here.
45pub struct BudgetedClient {
46    inner: Arc<dyn LlmClient>,
47    remaining: AtomicU32,
48}
49
50impl BudgetedClient {
51    pub fn new(inner: Arc<dyn LlmClient>, budget: u32) -> Self {
52        Self {
53            inner,
54            remaining: AtomicU32::new(budget),
55        }
56    }
57}
58
59impl LlmClient for BudgetedClient {
60    fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict> {
61        // Atomic fetch-decrement with saturating underflow guard.
62        loop {
63            let current = self.remaining.load(Ordering::Acquire);
64            if current == 0 {
65                return None;
66            }
67            if self
68                .remaining
69                .compare_exchange(current, current - 1, Ordering::AcqRel, Ordering::Acquire)
70                .is_ok()
71            {
72                break;
73            }
74        }
75        self.inner.evaluate(system_prompt, user_prompt)
76    }
77}
78
79/// Build the LLM client stack described by `cfg`. `force_off=true` mirrors
80/// `--no-llm` and always yields a null client, regardless of config.
81///
82/// Providers other than `Anthropic` are not implemented yet — they fall back
83/// to the null client with a warning so config authors can migrate once the
84/// adapters ship, without breaking existing runs.
85pub fn build_client(cfg: &LlmConfig, force_off: bool) -> Arc<dyn LlmClient> {
86    if force_off || !cfg.enabled {
87        return Arc::new(NullLlmClient);
88    }
89
90    let inner: Arc<dyn LlmClient> = match cfg.provider {
91        LlmProvider::Anthropic => match AnthropicLlmClient::from_env(cfg.model.clone(), cfg.timeout_s)
92        {
93            Some(c) => Arc::new(c),
94            None => {
95                eprintln!(
96                    "spec-drift: [llm] enabled but ANTHROPIC_API_KEY is not set; \
97                     skipping LLM rules for this run."
98                );
99                Arc::new(NullLlmClient)
100            }
101        },
102        LlmProvider::OpenAi | LlmProvider::Local => {
103            eprintln!(
104                "spec-drift: [llm].provider {:?} is not implemented yet; \
105                 skipping LLM rules for this run.",
106                cfg.provider
107            );
108            Arc::new(NullLlmClient)
109        }
110    };
111
112    Arc::new(BudgetedClient::new(inner, cfg.max_calls))
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::sync::atomic::{AtomicU32, Ordering};
119
120    struct CountingClient {
121        calls: AtomicU32,
122    }
123
124    impl LlmClient for CountingClient {
125        fn evaluate(&self, _: &str, _: &str) -> Option<LlmVerdict> {
126            self.calls.fetch_add(1, Ordering::AcqRel);
127            Some(LlmVerdict {
128                match_spec: true,
129                reason: "ok".into(),
130            })
131        }
132    }
133
134    #[test]
135    fn budget_exhausts_and_stops_calling_inner() {
136        let inner = Arc::new(CountingClient {
137            calls: AtomicU32::new(0),
138        });
139        let client = BudgetedClient::new(inner.clone(), 2);
140
141        assert!(client.evaluate("s", "u").is_some());
142        assert!(client.evaluate("s", "u").is_some());
143        assert!(client.evaluate("s", "u").is_none());
144        assert!(client.evaluate("s", "u").is_none());
145
146        assert_eq!(inner.calls.load(Ordering::Acquire), 2);
147    }
148
149    #[test]
150    fn force_off_always_yields_null_client() {
151        let cfg = LlmConfig {
152            enabled: true,
153            ..LlmConfig::default()
154        };
155        let client = build_client(&cfg, true);
156        assert!(client.evaluate("s", "u").is_none());
157    }
158
159    #[test]
160    fn disabled_config_yields_null_client() {
161        let cfg = LlmConfig::default(); // enabled=false
162        let client = build_client(&cfg, false);
163        assert!(client.evaluate("s", "u").is_none());
164    }
165}