Skip to main content

lean_ctx/core/
active_inference.rs

1//! Active Inference Preload — predictive context loading.
2//!
3//! Uses the agent's task description and recent interactions to predict
4//! which providers and resources will be needed next, then preloads them
5//! into the session cache before the agent asks.
6//!
7//! Scientific basis: Active Inference (Friston 2010; Parr, Pezzulo & Friston 2022).
8//! The system acts to reduce expected surprise by preloading context that
9//! minimizes the predicted free energy of future queries.
10//!
11//! Strategy:
12//!   1. Parse task keywords → predict relevant provider actions
13//!   2. Score predictions using the provider bandit
14//!   3. Preload top-k predictions into session cache
15
16use crate::core::provider_bandit::ProviderBandit;
17
18/// A predicted preload action.
19#[derive(Debug, Clone)]
20pub struct PreloadPrediction {
21    pub provider_id: String,
22    pub action: String,
23    pub confidence: f64,
24    pub reason: String,
25}
26
27/// Keyword → provider action mappings.
28static KEYWORD_MAPPINGS: &[(&[&str], &str, &str)] = &[
29    (
30        &["bug", "error", "crash", "fix", "broken", "issue", "defect"],
31        "github",
32        "issues",
33    ),
34    (
35        &["bug", "error", "crash", "fix", "broken", "issue", "defect"],
36        "jira",
37        "issues",
38    ),
39    (
40        &["pr", "pull", "merge", "review", "branch"],
41        "github",
42        "pull_requests",
43    ),
44    (
45        &[
46            "database",
47            "table",
48            "schema",
49            "column",
50            "migration",
51            "sql",
52            "db",
53        ],
54        "postgres",
55        "schemas",
56    ),
57    (
58        &["sprint", "story", "epic", "velocity", "backlog"],
59        "jira",
60        "sprints",
61    ),
62    (
63        &["wiki", "doc", "documentation", "guide", "howto"],
64        "github",
65        "issues",
66    ),
67];
68
69/// Predict which provider actions should be preloaded based on the task.
70pub fn predict_preloads(
71    task_description: &str,
72    available_providers: &[String],
73    bandit: &mut ProviderBandit,
74    max_predictions: usize,
75) -> Vec<PreloadPrediction> {
76    let task_lower = task_description.to_lowercase();
77    let task_words: Vec<&str> = task_lower.split_whitespace().collect();
78
79    let mut predictions: Vec<PreloadPrediction> = Vec::new();
80
81    for &(keywords, provider, action) in KEYWORD_MAPPINGS {
82        if !available_providers.iter().any(|p| p == provider) {
83            continue;
84        }
85
86        let matching_keywords: Vec<&&str> = keywords
87            .iter()
88            .filter(|kw| task_words.iter().any(|tw| tw.contains(*kw)))
89            .collect();
90
91        if matching_keywords.is_empty() {
92            continue;
93        }
94
95        let keyword_confidence = matching_keywords.len() as f64 / keywords.len() as f64;
96
97        let task_type = infer_task_type(&task_lower);
98        let bandit_score = bandit.estimated_probability(&task_type, provider);
99
100        let combined = 0.6 * keyword_confidence + 0.4 * bandit_score;
101
102        if !predictions
103            .iter()
104            .any(|p| p.provider_id == provider && p.action == action)
105        {
106            predictions.push(PreloadPrediction {
107                provider_id: provider.to_string(),
108                action: action.to_string(),
109                confidence: combined,
110                reason: format!(
111                    "keywords: {}",
112                    matching_keywords
113                        .iter()
114                        .map(|k| **k)
115                        .collect::<Vec<_>>()
116                        .join(", ")
117                ),
118            });
119        }
120    }
121
122    predictions.sort_by(|a, b| {
123        b.confidence
124            .partial_cmp(&a.confidence)
125            .unwrap_or(std::cmp::Ordering::Equal)
126    });
127    predictions.truncate(max_predictions);
128    predictions
129}
130
131/// Simple task type inference from keywords. Public so the preload feedback loop
132/// can bucket outcomes by the same task type the prediction was scored under.
133#[must_use]
134pub fn infer_task_type(task: &str) -> String {
135    if task.contains("bug")
136        || task.contains("fix")
137        || task.contains("error")
138        || task.contains("crash")
139    {
140        "bugfix".into()
141    } else if task.contains("feature") || task.contains("add") || task.contains("implement") {
142        "feature".into()
143    } else if task.contains("refactor") || task.contains("clean") || task.contains("improve") {
144        "refactor".into()
145    } else {
146        "general".into()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn predict_bug_fix_suggests_issues() {
156        let mut bandit = ProviderBandit::new();
157        let providers = vec!["github".into(), "jira".into()];
158
159        let predictions = predict_preloads(
160            "Fix the authentication bug in the login flow",
161            &providers,
162            &mut bandit,
163            5,
164        );
165
166        assert!(!predictions.is_empty());
167        assert!(
168            predictions
169                .iter()
170                .any(|p| p.provider_id == "github" && p.action == "issues")
171        );
172    }
173
174    #[test]
175    fn predict_db_task_suggests_schemas() {
176        let mut bandit = ProviderBandit::new();
177        let providers = vec!["postgres".into()];
178
179        let predictions = predict_preloads(
180            "Add a new column to the users database table",
181            &providers,
182            &mut bandit,
183            5,
184        );
185
186        assert!(
187            predictions
188                .iter()
189                .any(|p| p.provider_id == "postgres" && p.action == "schemas")
190        );
191    }
192
193    #[test]
194    fn predict_pr_review_suggests_pull_requests() {
195        let mut bandit = ProviderBandit::new();
196        let providers = vec!["github".into()];
197
198        let predictions = predict_preloads(
199            "Review the open pull requests and merge the approved ones",
200            &providers,
201            &mut bandit,
202            5,
203        );
204
205        assert!(
206            predictions
207                .iter()
208                .any(|p| p.provider_id == "github" && p.action == "pull_requests")
209        );
210    }
211
212    #[test]
213    fn predict_empty_task_returns_empty() {
214        let mut bandit = ProviderBandit::new();
215        let predictions = predict_preloads("", &["github".into()], &mut bandit, 5);
216        assert!(predictions.is_empty());
217    }
218
219    #[test]
220    fn predict_unavailable_provider_skipped() {
221        let mut bandit = ProviderBandit::new();
222        let predictions = predict_preloads(
223            "Fix the database schema migration",
224            &["github".into()], // postgres not available
225            &mut bandit,
226            5,
227        );
228
229        assert!(!predictions.iter().any(|p| p.provider_id == "postgres"));
230    }
231
232    #[test]
233    fn predict_respects_max_predictions() {
234        let mut bandit = ProviderBandit::new();
235        let providers = vec!["github".into(), "jira".into(), "postgres".into()];
236
237        let predictions = predict_preloads(
238            "Fix the bug in database schema and review pull requests",
239            &providers,
240            &mut bandit,
241            2,
242        );
243
244        assert!(predictions.len() <= 2);
245    }
246
247    #[test]
248    fn predict_bandit_trained_boosts_confidence() {
249        let mut bandit = ProviderBandit::new();
250        for _ in 0..20 {
251            bandit.update("bugfix", "github", true);
252            bandit.update("bugfix", "jira", false);
253        }
254
255        let providers = vec!["github".into(), "jira".into()];
256        let predictions = predict_preloads(
257            "Fix the crash bug in authentication",
258            &providers,
259            &mut bandit,
260            5,
261        );
262
263        let gh = predictions
264            .iter()
265            .find(|p| p.provider_id == "github" && p.action == "issues");
266        let jira = predictions
267            .iter()
268            .find(|p| p.provider_id == "jira" && p.action == "issues");
269
270        if let (Some(gh), Some(jira)) = (gh, jira) {
271            assert!(
272                gh.confidence > jira.confidence,
273                "Trained bandit should boost github over jira"
274            );
275        }
276    }
277
278    #[test]
279    fn infer_task_type_correctness() {
280        assert_eq!(infer_task_type("fix the crash bug"), "bugfix");
281        assert_eq!(infer_task_type("add new feature"), "feature");
282        assert_eq!(infer_task_type("refactor the auth module"), "refactor");
283        assert_eq!(infer_task_type("update documentation"), "general");
284    }
285}