Skip to main content

lean_ctx/core/triage/
rules.rs

1use super::{
2    ProfileHypothesis, TaskAnalysisInput, TaskAnalyzer, TriageBackendLocal,
3    confidence::{RULES_FALLBACK_MILLI, clamp_milli},
4    profile::{TaskProfileLocal, TaskScopeLocal},
5};
6use crate::core::{
7    adaptive::TaskComplexity,
8    intent_engine::{self, IntentScope, StructuredIntent, TaskType},
9};
10
11#[derive(Debug, Clone, Copy, Default)]
12pub struct RuleTriageBackend;
13
14impl TaskAnalyzer for RuleTriageBackend {
15    fn analyze(&self, input: &TaskAnalysisInput) -> Result<ProfileHypothesis, super::TriageError> {
16        if input.query.trim().is_empty() {
17            return Ok(fallback());
18        }
19        let classification = intent_engine::classify(&input.query);
20        let structured =
21            StructuredIntent::from_query_with_session(&input.query, &input.files_touched);
22        let (complexity, reasoning) = complexity_values(intent_engine::classify_complexity(
23            &input.query,
24            &classification,
25        ));
26        let confidence = milli(structured.confidence);
27        let (scope, scope_base) = scope_values(structured.scope);
28        let profile = TaskProfileLocal {
29            task_class: "coding".into(),
30            intent: intent_name(structured.task_type).into(),
31            complexity: complexity.into(),
32            scope,
33            context_need_milli: clamp_milli(
34                scope_base
35                    + input.files_touched.len().min(6) as u16 * 25
36                    + u16::from(input.session_context.is_some()) * 50,
37            ),
38            reasoning_need_milli: reasoning,
39            risk_signal_milli: clamp_milli(
40                input.active_diagnostics.saturating_mul(100).min(600) as u16
41                    + u16::from(matches!(
42                        structured.task_type,
43                        TaskType::Config | TaskType::Deploy
44                    )) * 200,
45            ),
46            confidence_milli: confidence,
47        };
48        Ok(ProfileHypothesis {
49            profile,
50            confidence_milli: confidence,
51            backend: TriageBackendLocal::Rules,
52        })
53    }
54    fn name(&self) -> &'static str {
55        "rules"
56    }
57}
58
59fn fallback() -> ProfileHypothesis {
60    let profile = TaskProfileLocal {
61        task_class: "coding".into(),
62        intent: "explore".into(),
63        confidence_milli: RULES_FALLBACK_MILLI,
64        ..Default::default()
65    };
66    ProfileHypothesis {
67        profile,
68        confidence_milli: RULES_FALLBACK_MILLI,
69        backend: TriageBackendLocal::Rules,
70    }
71}
72fn milli(value: f64) -> u16 {
73    if value.is_finite() {
74        clamp_milli((value.clamp(0.0, 1.0) * 1000.0).round() as u16)
75    } else {
76        // A non-finite confidence is an absent signal, not a middling one — it
77        // reports the same guess-level confidence as `fallback()` so it stays
78        // below `ACTIONABLE_FLOOR_MILLI` (#1484).
79        RULES_FALLBACK_MILLI
80    }
81}
82fn intent_name(t: TaskType) -> &'static str {
83    match t {
84        TaskType::FixBug => "coding_fix",
85        _ => t.as_str(),
86    }
87}
88fn complexity_values(c: TaskComplexity) -> (&'static str, u16) {
89    match c {
90        TaskComplexity::Mechanical => ("low", 250),
91        TaskComplexity::Standard => ("medium", 550),
92        TaskComplexity::Architectural => ("high", 850),
93    }
94}
95fn scope_values(s: IntentScope) -> (TaskScopeLocal, u16) {
96    match s {
97        IntentScope::SingleFile => (TaskScopeLocal::SingleFile, 250),
98        IntentScope::MultiFile => (TaskScopeLocal::MultiFile, 500),
99        IntentScope::CrossModule => (TaskScopeLocal::CrossModule, 750),
100        IntentScope::ProjectWide => (TaskScopeLocal::CrossProject, 900),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    fn intent(query: &str) -> String {
108        RuleTriageBackend
109            .analyze(&TaskAnalysisInput {
110                query: query.into(),
111                ..Default::default()
112            })
113            .unwrap()
114            .profile
115            .intent
116    }
117    #[test]
118    fn maps_all_intents() {
119        for (query, expected) in [
120            ("generate code", "generate"),
121            ("fix bug", "coding_fix"),
122            ("refactor", "refactor"),
123            ("explain", "explore"),
124            ("test", "test"),
125            ("debug", "debug"),
126            ("configure", "config"),
127            ("deploy", "deploy"),
128            ("review", "review"),
129        ] {
130            assert_eq!(intent(query), expected);
131        }
132    }
133    #[test]
134    fn fallback_has_low_confidence() {
135        assert_eq!(
136            RuleTriageBackend
137                .analyze(&TaskAnalysisInput::default())
138                .unwrap()
139                .confidence_milli,
140            RULES_FALLBACK_MILLI
141        );
142    }
143    #[test]
144    fn non_finite_confidence_stays_below_the_actionable_floor() {
145        use crate::core::triage::confidence::ACTIONABLE_FLOOR_MILLI;
146
147        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
148            assert!(
149                milli(value) < ACTIONABLE_FLOOR_MILLI,
150                "a non-finite confidence is an absent signal, not an actionable one"
151            );
152        }
153    }
154}