Skip to main content

lean_ctx/core/
intent_router.rs

1use serde::Serialize;
2
3use crate::core::context_ledger::PressureAction;
4use crate::core::intent_engine::{IntentDimension, TaskType, classify, route_intent};
5
6/// Context strategy only (the read view `ctx_plan` uses). Model and provider
7/// selection belong to the caller.
8#[derive(Debug, Clone, Serialize)]
9pub struct IntentRouteV2 {
10    pub inputs: IntentRouteInputsV2,
11    pub decision: IntentRouteDecisionV2,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct IntentRouteInputsV2 {
16    /// BLAKE3 hex; the serialized name keeps its historical `_md5` suffix.
17    #[serde(rename = "query_md5")]
18    pub query_hash: String,
19    pub query_redacted: String,
20    pub role: String,
21    pub profile: String,
22    pub task_type: TaskType,
23    pub confidence: f64,
24    pub dimension: IntentDimension,
25    pub pressure: PressureSummaryV1,
26    /// BLAKE3 hex; the serialized name keeps its historical `_md5` suffix.
27    #[serde(rename = "policy_md5")]
28    pub policy_hash: String,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct PressureSummaryV1 {
33    pub utilization_pct: u8,
34    pub remaining_tokens: usize,
35    pub action: PressureActionV1,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PressureActionV1 {
41    NoAction,
42    SuggestCompression,
43    ForceCompression,
44    EvictLeastRelevant,
45}
46
47impl PressureActionV1 {
48    fn from_action(a: PressureAction) -> Self {
49        match a {
50            PressureAction::NoAction => Self::NoAction,
51            PressureAction::SuggestCompression => Self::SuggestCompression,
52            PressureAction::ForceCompression => Self::ForceCompression,
53            PressureAction::EvictLeastRelevant => Self::EvictLeastRelevant,
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct IntentRouteDecisionV2 {
60    pub recommended_read_mode: String,
61    pub effective_read_mode: String,
62    pub degraded_by_pressure: bool,
63    pub reason: String,
64}
65
66#[derive(Debug, Clone)]
67pub(crate) struct RouteInputs {
68    pub pressure_action: PressureAction,
69    pub pressure_utilization: f64,
70    pub pressure_remaining_tokens: usize,
71}
72
73pub fn route_v2(query: &str) -> IntentRouteV2 {
74    let ledger = crate::core::context_ledger::ContextLedger::load();
75    let pressure = ledger.pressure();
76    let profile_name = crate::core::profiles::active_profile_name();
77    let profile = crate::core::profiles::active_profile();
78    let role_name = crate::core::roles::active_role_name();
79
80    let inputs = RouteInputs {
81        pressure_action: pressure.recommendation,
82        pressure_utilization: pressure.utilization,
83        pressure_remaining_tokens: pressure.remaining_tokens,
84    };
85
86    route_v2_with(query, &role_name, &profile_name, &profile.routing, &inputs)
87}
88
89fn route_v2_with(
90    query: &str,
91    role_name: &str,
92    profile_name: &str,
93    routing: &crate::core::profiles::RoutingConfig,
94    inputs: &RouteInputs,
95) -> IntentRouteV2 {
96    let classification = classify(query);
97    let base = route_intent(query, &classification);
98    let query_redacted = truncate(&crate::core::redaction::redact_text(query), 180);
99    let query_hash = crate::core::hasher::hash_str(query);
100    let policy_hash = crate::core::hasher::hash_str(&format!(
101        "context_strategy_v2;degrade_under_pressure={}",
102        routing.degrade_under_pressure_effective()
103    ));
104
105    let recommended_read_mode = read_mode_for_task(classification.task_type);
106    let (effective_read_mode, degraded_by_pressure) = if routing.degrade_under_pressure_effective()
107    {
108        apply_pressure_degrade(&recommended_read_mode, inputs.pressure_action)
109    } else {
110        (recommended_read_mode.clone(), false)
111    };
112    let reason = format!(
113        "task={} dim={} read={} pressure={:?} degraded_by_pressure={}",
114        classification.task_type.as_str(),
115        base.dimension.as_str(),
116        effective_read_mode,
117        inputs.pressure_action,
118        degraded_by_pressure
119    );
120
121    IntentRouteV2 {
122        inputs: IntentRouteInputsV2 {
123            query_hash,
124            query_redacted,
125            role: role_name.to_string(),
126            profile: profile_name.to_string(),
127            task_type: classification.task_type,
128            confidence: classification.confidence,
129            dimension: base.dimension,
130            pressure: PressureSummaryV1 {
131                utilization_pct: (inputs.pressure_utilization * 100.0).min(254.0) as u8,
132                remaining_tokens: inputs.pressure_remaining_tokens,
133                action: PressureActionV1::from_action(inputs.pressure_action),
134            },
135            policy_hash,
136        },
137        decision: IntentRouteDecisionV2 {
138            recommended_read_mode,
139            effective_read_mode,
140            degraded_by_pressure,
141            reason,
142        },
143    }
144}
145
146pub(crate) fn read_mode_for_task(task_type: TaskType) -> String {
147    // Editing and debugging require complete source, independently of the
148    // caller's model. Exploration/review can use a structural or semantic view.
149    match task_type {
150        TaskType::Explore | TaskType::Review => "map".to_string(),
151        _ => "full".to_string(),
152    }
153}
154
155fn apply_pressure_degrade(mode: &str, pressure: PressureAction) -> (String, bool) {
156    if let Some(downgraded) = crate::core::auto_mode_resolver::pressure_downgrade(mode, &pressure) {
157        (downgraded, true)
158    } else {
159        (mode.to_string(), false)
160    }
161}
162
163fn truncate(s: &str, max: usize) -> String {
164    if s.chars().count() <= max {
165        return s.to_string();
166    }
167    s.chars().take(max).collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn no_pressure() -> RouteInputs {
175        RouteInputs {
176            pressure_action: PressureAction::NoAction,
177            pressure_utilization: 0.1,
178            pressure_remaining_tokens: 1000,
179        }
180    }
181
182    #[test]
183    fn routing_is_deterministic_for_same_inputs() {
184        let routing = crate::core::profiles::RoutingConfig::default();
185        let inputs = no_pressure();
186        let a = route_v2_with(
187            "fix bug in src/lib.rs",
188            "coder",
189            "bugfix",
190            &routing,
191            &inputs,
192        );
193        let b = route_v2_with(
194            "fix bug in src/lib.rs",
195            "coder",
196            "bugfix",
197            &routing,
198            &inputs,
199        );
200        assert_eq!(
201            serde_json::to_string(&a).unwrap(),
202            serde_json::to_string(&b).unwrap()
203        );
204    }
205
206    #[test]
207    fn route_carries_no_model_or_budget_fields() {
208        let inputs = no_pressure();
209        let routing = crate::core::profiles::RoutingConfig::default();
210        for query in [
211            "explore the codebase",
212            "fix bug in src/lib.rs",
213            "implement feature x",
214        ] {
215            let route = route_v2_with(query, "coder", "same-profile", &routing, &inputs);
216            let payload = serde_json::to_value(&route).unwrap();
217            assert!(payload.get("created_at").is_none());
218            assert!(payload["inputs"].get("budgets").is_none());
219            // The wire keeps the historical names of the BLAKE3 hashes.
220            assert_eq!(
221                payload["inputs"]["query_md5"],
222                crate::core::hasher::hash_str(query)
223            );
224            assert!(payload["inputs"]["policy_md5"].is_string());
225            for removed in [
226                "recommended_model_tier",
227                "effective_model_tier",
228                "degraded_by_budget",
229            ] {
230                assert!(payload["decision"].get(removed).is_none());
231            }
232        }
233        for task in [
234            TaskType::Generate,
235            TaskType::FixBug,
236            TaskType::Refactor,
237            TaskType::Debug,
238        ] {
239            assert_eq!(read_mode_for_task(task), "full");
240        }
241    }
242
243    #[test]
244    fn pressure_forces_degraded_mode() {
245        let routing = crate::core::profiles::RoutingConfig::default();
246        let inputs = RouteInputs {
247            pressure_action: PressureAction::EvictLeastRelevant,
248            pressure_utilization: 0.95,
249            pressure_remaining_tokens: 100,
250        };
251        let route = route_v2_with(
252            "review the auth module",
253            "coder",
254            "review",
255            &routing,
256            &inputs,
257        );
258        assert!(
259            matches!(
260                route.decision.effective_read_mode.as_str(),
261                "signatures" | "cognitive" | "reference"
262            ),
263            "expected degraded mode, got: {}",
264            route.decision.effective_read_mode
265        );
266        assert!(route.decision.degraded_by_pressure);
267    }
268}