1#![allow(missing_docs)]
4
5use crate::messages::{ContentBlock, Message};
12
13use super::types::{RouterPhase, RouterTier, RoutingDecision};
14
15#[derive(Debug, Clone, Default)]
19pub struct StructuralSignal {
20 pub message_count: usize,
21 pub tool_call_count: usize,
22 pub tool_result_count: usize,
23 pub estimated_tokens: usize,
24 pub user_message_count: usize,
25}
26
27impl StructuralSignal {
28 pub fn extract(messages: &[Message]) -> Self {
30 let mut signal = Self {
31 message_count: messages.len(),
32 ..Default::default()
33 };
34 let mut total_chars: usize = 0;
35
36 for msg in messages {
37 match msg {
38 Message::User(u) => {
39 signal.user_message_count += 1;
40 total_chars += match &u.content {
41 crate::messages::MessageContent::Text(s) => s.len(),
42 crate::messages::MessageContent::Blocks(blocks) => blocks
43 .iter()
44 .map(|b| match b {
45 ContentBlock::Text(t) => t.text.len(),
46 ContentBlock::Image(img) => img.data.len() / 4,
47 _ => 16,
48 })
49 .sum(),
50 };
51 }
52 Message::Assistant(a) => {
53 for block in &a.content {
54 match block {
55 ContentBlock::Text(t) => total_chars += t.text.len(),
56 ContentBlock::Thinking(t) => total_chars += t.thinking.len(),
57 ContentBlock::ToolCall(_) => signal.tool_call_count += 1,
58 ContentBlock::Image(img) => total_chars += img.data.len() / 4,
59 ContentBlock::Unknown(v) => total_chars += v.to_string().len(),
60 }
61 }
62 }
63 Message::ToolResult(t) => {
64 signal.tool_result_count += 1;
65 for block in &t.content {
66 if let ContentBlock::Text(txt) = block {
67 total_chars += txt.text.len();
68 }
69 }
70 }
71 }
72 }
73
74 signal.estimated_tokens = total_chars / 4;
75 signal
76 }
77
78 pub fn normalized(&self) -> f64 {
80 let msg_factor = (self.message_count as f64).ln_1p() / 10.0_f64.ln_1p();
81 let tool_factor = (self.tool_call_count as f64).ln_1p() / 20.0_f64.ln_1p();
82 let token_factor = (self.estimated_tokens as f64).ln_1p() / 100_000.0_f64.ln_1p();
83 (0.3 * msg_factor + 0.4 * tool_factor + 0.3 * token_factor).clamp(0.0, 1.0)
84 }
85}
86
87#[derive(Debug, Clone, Default)]
91pub struct BehavioralSignal {
92 pub phase: RouterPhase,
93 pub recent_tool_count: usize,
94 pub phase_transitions: usize,
95 pub is_question: bool,
96}
97
98impl BehavioralSignal {
99 pub fn extract(messages: &[Message], history: &[RoutingDecision]) -> Self {
101 let phase = Self::detect_phase(messages);
102 let recent_tool_count = Self::count_recent_tools(messages, 10);
103 let phase_transitions = Self::count_phase_transitions(history);
104 let is_question = Self::detect_question(messages);
105
106 Self {
107 phase,
108 recent_tool_count,
109 phase_transitions,
110 is_question,
111 }
112 }
113
114 fn detect_phase(messages: &[Message]) -> RouterPhase {
115 let recent = messages.len().saturating_sub(6);
116 let recent_msgs = &messages[recent..];
117
118 let tool_calls_in_recent: usize = recent_msgs
119 .iter()
120 .map(|m| match m {
121 Message::Assistant(a) => a
122 .content
123 .iter()
124 .filter(|b| matches!(b, ContentBlock::ToolCall(_)))
125 .count(),
126 _ => 0,
127 })
128 .sum();
129
130 let user_msgs_in_recent: usize = recent_msgs
131 .iter()
132 .filter(|m| matches!(m, Message::User(_)))
133 .count();
134
135 if tool_calls_in_recent >= 3 {
136 RouterPhase::Implementation
137 } else if user_msgs_in_recent >= 2 && tool_calls_in_recent == 0 {
138 RouterPhase::Planning
139 } else {
140 RouterPhase::Lightweight
141 }
142 }
143
144 pub fn count_recent_tools(messages: &[Message], n: usize) -> usize {
146 let start = messages.len().saturating_sub(n);
147 messages[start..]
148 .iter()
149 .filter(|m| matches!(m, Message::ToolResult(_)))
150 .count()
151 }
152
153 pub fn count_phase_transitions(history: &[RoutingDecision]) -> usize {
155 if history.len() < 2 {
156 return 0;
157 }
158 history
159 .windows(2)
160 .filter(|w| w[0].phase != w[1].phase)
161 .count()
162 }
163
164 fn detect_question(messages: &[Message]) -> bool {
165 messages
166 .iter()
167 .rev()
168 .find_map(|m| match m {
169 Message::User(u) => Some(u.content.as_str().unwrap_or("").to_lowercase()),
170 _ => None,
171 })
172 .map(|text| {
173 text.ends_with('?')
174 || text.starts_with("what")
175 || text.starts_with("how")
176 || text.starts_with("why")
177 || text.starts_with("when")
178 || text.starts_with("where")
179 || text.starts_with("who")
180 || text.starts_with("explain")
181 })
182 .unwrap_or(false)
183 }
184
185 pub fn normalized(&self) -> f64 {
187 let phase_weight = self.phase.weight();
188 let tool_factor = (self.recent_tool_count as f64 / 10.0).min(1.0);
189 (0.5 * phase_weight + 0.3 * tool_factor + 0.2 * self.phase_transitions as f64)
190 .clamp(0.0, 1.0)
191 }
192}
193
194#[derive(Debug, Clone, Default)]
198pub struct ContextBudgetSignal {
199 pub estimated_tokens: usize,
200 pub accumulated_cost: f64,
201 pub budget_limit: Option<f64>,
202 pub context_upgrade_threshold: Option<usize>,
203}
204
205impl ContextBudgetSignal {
206 pub fn extract(
208 estimated_tokens: usize,
209 accumulated_cost: f64,
210 budget_limit: Option<f64>,
211 context_upgrade_threshold: Option<usize>,
212 ) -> Self {
213 Self {
214 estimated_tokens,
215 accumulated_cost,
216 budget_limit,
217 context_upgrade_threshold,
218 }
219 }
220
221 pub fn should_upgrade_context(&self) -> bool {
223 self.context_upgrade_threshold
224 .map(|t| self.estimated_tokens > t)
225 .unwrap_or(false)
226 }
227
228 pub fn is_over_budget(&self) -> bool {
230 self.budget_limit
231 .map(|l| self.accumulated_cost >= l)
232 .unwrap_or(false)
233 }
234
235 pub fn budget_utilization(&self) -> f64 {
237 self.budget_limit
238 .map(|l| (self.accumulated_cost / l).min(1.0))
239 .unwrap_or(0.0)
240 }
241
242 pub fn normalized(&self) -> f64 {
244 let token_factor = (self.estimated_tokens as f64 / 200_000.0).min(1.0);
245 let budget_factor = self.budget_utilization();
246 (0.6 * token_factor + 0.4 * budget_factor).clamp(0.0, 1.0)
247 }
248}
249
250#[derive(Debug, Clone, Default)]
257pub struct VisionSignal {
258 pub recent_image_count: usize,
260 pub has_image_in_latest_turn: bool,
262 pub image_producing_tools: Vec<String>,
264}
265
266impl VisionSignal {
267 pub fn extract(messages: &[Message], window: usize) -> Self {
269 let start = messages.len().saturating_sub(window);
270 let recent = &messages[start..];
271
272 let mut signal = Self::default();
273
274 for msg in messages.iter().rev() {
276 if let Message::User(u) = msg {
277 if let crate::messages::MessageContent::Blocks(blocks) = &u.content {
278 for b in blocks {
279 if let ContentBlock::Image(_) = b {
280 signal.has_image_in_latest_turn = true;
281 }
282 }
283 }
284 break; }
286 }
287
288 for msg in recent {
290 match msg {
291 Message::User(u) => {
292 if let crate::messages::MessageContent::Blocks(blocks) = &u.content {
293 for b in blocks {
294 if let ContentBlock::Image(_) = b {
295 signal.recent_image_count += 1;
296 }
297 }
298 }
299 }
300 Message::ToolResult(t) => {
301 let has_image = t
302 .content
303 .iter()
304 .any(|b| matches!(b, ContentBlock::Image(_)));
305 if has_image {
306 signal.recent_image_count += 1;
307 if !signal.image_producing_tools.contains(&t.tool_name) {
309 signal.image_producing_tools.push(t.tool_name.clone());
310 }
311 }
312 }
313 _ => {}
314 }
315 }
316
317 signal
318 }
319
320 pub fn requires_vision(&self) -> bool {
322 self.recent_image_count > 0 || self.has_image_in_latest_turn
323 }
324
325 pub fn normalized(&self) -> f64 {
331 if self.recent_image_count == 0 && !self.has_image_in_latest_turn {
332 return 0.0;
333 }
334 let count = self
335 .recent_image_count
336 .max(if self.has_image_in_latest_turn { 1 } else { 0 });
337 1.0 - (-0.8 * count as f64).exp()
339 }
340}
341
342#[derive(Debug, Clone, Default)]
349pub struct MessageContentSignal {
350 pub message_length: usize,
352 pub line_count: usize,
354 pub has_code_blocks: bool,
356 pub file_path_count: usize,
358 pub symbol_density: f64,
360 pub is_question: bool,
362 pub is_single_sentence: bool,
364}
365
366impl MessageContentSignal {
367 pub fn extract(messages: &[Message]) -> Self {
369 let last_user_text = messages
370 .iter()
371 .rev()
372 .find_map(|m| match m {
373 Message::User(u) => Some(u.content.as_str().unwrap_or("").to_string()),
374 _ => None,
375 })
376 .unwrap_or_default();
377 Self::from_text(&last_user_text)
378 }
379
380 pub fn from_text(text: &str) -> Self {
382 let bytes = text.as_bytes();
383 let message_length = text.len();
384 let line_count = text.lines().count().max(1);
385 let has_code_blocks = text.contains("```");
386
387 let mut file_path_count = 0usize;
389 let mut i = 0;
390 while i < bytes.len() {
391 if bytes[i] == b'/' || bytes[i] == b'\\' {
392 for j in (i + 1)..std::cmp::min(i + 20, bytes.len()) {
393 if bytes[j] == b'.' && j + 1 < bytes.len() && bytes[j + 1].is_ascii_alphabetic()
394 {
395 file_path_count += 1;
396 i = j + 1;
397 break;
398 }
399 }
400 }
401 i += 1;
402 }
403
404 let code_symbols: &[u8] = b"{}()[]<>=;|&!@#$%^*+-/:\\";
406 let symbol_count = text.bytes().filter(|b| code_symbols.contains(b)).count();
407 let symbol_density = if text.is_empty() {
408 0.0
409 } else {
410 symbol_count as f64 / text.len() as f64
411 };
412
413 let trimmed = text.trim();
414 let is_question = trimmed.ends_with('?');
415 let is_single_sentence = !trimmed.contains('\n') && trimmed.split_whitespace().count() <= 3;
416
417 Self {
418 message_length,
419 line_count,
420 has_code_blocks,
421 file_path_count,
422 symbol_density,
423 is_question,
424 is_single_sentence,
425 }
426 }
427
428 pub fn normalized(&self) -> f64 {
438 let mut score = 0.0;
439
440 score += match self.message_length {
442 0..=20 => 0.0,
443 21..=60 => 0.05,
444 61..=200 => 0.10,
445 201..=600 => 0.15,
446 601..=2000 => 0.20,
447 _ => 0.25,
448 };
449
450 score += match self.line_count {
452 1 => 0.0,
453 2..=3 => 0.03,
454 4..=10 => 0.08,
455 _ => 0.15,
456 };
457
458 if self.has_code_blocks {
460 score += 0.15;
461 }
462
463 score += (0.05 * self.file_path_count.min(3) as f64).min(0.15);
465
466 score += match self.symbol_density {
468 d if d < 0.03 => 0.0,
469 d if d < 0.08 => 0.03,
470 d if d < 0.15 => 0.08,
471 _ => 0.15,
472 };
473
474 if self.is_single_sentence {
476 score -= 0.08;
477 }
478 if self.is_question && self.message_length < 80 {
479 score -= 0.06;
480 }
481
482 score.clamp(0.0, 1.0)
483 }
484
485 pub fn decisive_tier(&self) -> Option<RouterTier> {
491 if self.message_length < 15 && self.is_single_sentence && !self.has_code_blocks {
493 return Some(RouterTier::Low);
494 }
495 if self.message_length > 500 && self.has_code_blocks && self.file_path_count >= 2 {
497 return Some(RouterTier::High);
498 }
499 None
500 }
501}
502
503#[cfg(test)]
504mod vision_tests {
505 use super::*;
506 use crate::messages::{TextContent, ToolResultMessage, UserMessage};
507
508 fn text_user_msg(s: &str) -> Message {
509 Message::User(UserMessage {
510 role: crate::messages::UserRole::User,
511 content: crate::messages::MessageContent::Text(s.to_string()),
512 timestamp: 0,
513 })
514 }
515
516 fn image_user_msg() -> Message {
517 Message::User(UserMessage {
518 role: crate::messages::UserRole::User,
519 content: crate::messages::MessageContent::Blocks(vec![ContentBlock::Image(
520 crate::messages::ImageContent {
521 content_type: crate::messages::ImageContentType::Image,
522 data: "fake".to_string(),
523 mime_type: "image/png".to_string(),
524 },
525 )]),
526 timestamp: 0,
527 })
528 }
529
530 fn text_tool_result() -> Message {
531 Message::ToolResult(ToolResultMessage {
532 role: crate::messages::ToolResultRole::ToolResult,
533 tool_call_id: "t1".to_string(),
534 tool_name: "bash".to_string(),
535 content: vec![ContentBlock::Text(TextContent {
536 content_type: crate::messages::TextContentType::Text,
537 text: "done".to_string(),
538 text_signature: None,
539 })],
540 details: None,
541 is_error: false,
542 timestamp: 0,
543 })
544 }
545
546 fn image_tool_result(tool: &str) -> Message {
547 Message::ToolResult(ToolResultMessage {
548 role: crate::messages::ToolResultRole::ToolResult,
549 tool_call_id: "t2".to_string(),
550 tool_name: tool.to_string(),
551 content: vec![ContentBlock::Image(crate::messages::ImageContent {
552 content_type: crate::messages::ImageContentType::Image,
553 data: "fake".to_string(),
554 mime_type: "image/png".to_string(),
555 })],
556 details: None,
557 is_error: false,
558 timestamp: 0,
559 })
560 }
561
562 #[test]
563 fn vision_no_images() {
564 let msgs = vec![text_user_msg("hello")];
565 let signal = VisionSignal::extract(&msgs, 10);
566 assert!(!signal.requires_vision());
567 assert_eq!(signal.recent_image_count, 0);
568 }
569
570 #[test]
571 fn vision_user_image() {
572 let msgs = vec![image_user_msg()];
573 let signal = VisionSignal::extract(&msgs, 10);
574 assert!(signal.requires_vision());
575 assert!(signal.has_image_in_latest_turn);
576 }
577
578 #[test]
579 fn vision_tool_result_image() {
580 let msgs = vec![text_user_msg("look"), image_tool_result("browse")];
581 let signal = VisionSignal::extract(&msgs, 10);
582 assert!(signal.requires_vision());
583 assert_eq!(signal.recent_image_count, 1);
584 assert!(signal.image_producing_tools.contains(&"browse".to_string()));
585 }
586
587 #[test]
588 fn vision_browse_screenshot() {
589 let msgs = vec![image_tool_result("browse")];
590 let signal = VisionSignal::extract(&msgs, 10);
591 assert!(signal.requires_vision());
592 assert!(signal.image_producing_tools.contains(&"browse".to_string()));
593 }
594
595 #[test]
596 fn vision_normalized_zero() {
597 let signal = VisionSignal::default();
598 assert!((signal.normalized() - 0.0).abs() < 1e-6);
599 }
600
601 #[test]
602 fn vision_normalized_single() {
603 let signal = VisionSignal {
604 recent_image_count: 1,
605 has_image_in_latest_turn: true,
606 image_producing_tools: vec![],
607 };
608 let n = signal.normalized();
609 assert!(n > 0.5, "single image normalized = {}", n);
610 assert!(n <= 1.0);
611 }
612
613 #[test]
614 fn vision_normalized_multiple() {
615 let signal = VisionSignal {
616 recent_image_count: 3,
617 has_image_in_latest_turn: true,
618 image_producing_tools: vec![],
619 };
620 let n = signal.normalized();
621 assert!(n > 0.9, "3 images normalized = {}", n);
622 }
623
624 #[test]
625 fn vision_window_respected() {
626 let msgs: Vec<Message> = (0..20)
627 .flat_map(|_| vec![text_user_msg("hi"), image_tool_result("browse")])
628 .collect();
629 let signal_full = VisionSignal::extract(&msgs, 100);
630 let signal_windowed = VisionSignal::extract(&msgs, 4);
631 assert!(signal_full.recent_image_count > signal_windowed.recent_image_count);
632 }
633
634 #[test]
635 fn vision_text_only_tool_result() {
636 let msgs = vec![text_user_msg("run"), text_tool_result()];
637 let signal = VisionSignal::extract(&msgs, 10);
638 assert!(!signal.requires_vision());
639 }
640}
641
642#[cfg(test)]
643mod message_content_tests {
644 use super::*;
645 use crate::messages::UserMessage;
646
647 fn text_user_msg(s: &str) -> Message {
648 Message::User(UserMessage {
649 role: crate::messages::UserRole::User,
650 content: crate::messages::MessageContent::Text(s.to_string()),
651 timestamp: 0,
652 })
653 }
654
655 #[test]
658 fn msg_empty() {
659 let sig = MessageContentSignal::from_text("");
660 assert!(sig.normalized() < 0.05);
661 }
662
663 #[test]
664 fn msg_greeting() {
665 let sig = MessageContentSignal::from_text("hello");
666 assert!(sig.normalized() < 0.1);
667 assert!(sig.is_single_sentence);
668 }
669
670 #[test]
671 fn msg_korean_greeting() {
672 let sig = MessageContentSignal::from_text("안녕하세요");
673 assert!(sig.normalized() < 0.1);
674 }
675
676 #[test]
677 fn msg_short_question() {
678 let sig = MessageContentSignal::from_text("what is rust?");
679 assert!(sig.normalized() < 0.1);
680 assert!(sig.is_question);
681 }
682
683 #[test]
686 fn msg_moderate() {
687 let sig = MessageContentSignal::from_text(
688 "Modify the config file to add the new endpoint for the auth service",
689 );
690 assert!((0.02..0.25).contains(&sig.normalized()));
691 }
692
693 #[test]
694 fn msg_multiline() {
695 let sig =
696 MessageContentSignal::from_text("I need to update:\n- config\n- router\n- middleware");
697 assert!(sig.normalized() > 0.05);
698 assert_eq!(sig.line_count, 4);
699 }
700
701 #[test]
704 fn msg_code_blocks() {
705 let sig = MessageContentSignal::from_text(
706 "Debug:\n```rust\nfn main() { panic!() }\n```\nStack trace shows null.",
707 );
708 assert!(sig.normalized() > 0.2);
709 assert!(sig.has_code_blocks);
710 }
711
712 #[test]
713 fn msg_multi_file() {
714 let sig =
715 MessageContentSignal::from_text("Update src/main.rs and lib/config.rs for the new API");
716 assert!(sig.file_path_count >= 2);
717 assert!(sig.normalized() > 0.1);
718 }
719
720 #[test]
721 fn msg_high_symbol_density() {
722 let sig = MessageContentSignal::from_text(
723 "{\"type\": \"router\", \"config\": {\"high\": {\"model\": \"opus\"}}}",
724 );
725 assert!(sig.symbol_density > 0.15);
726 }
727
728 #[test]
731 fn extract_from_messages() {
732 let msgs = vec![
733 text_user_msg("system prompt"),
734 text_user_msg("update src/main.rs"),
735 ];
736 let sig = MessageContentSignal::extract(&msgs);
737 assert_eq!(sig.message_length, 18); }
739
740 #[test]
743 fn decisive_low() {
744 let sig = MessageContentSignal::from_text("hi");
745 assert_eq!(sig.decisive_tier(), Some(RouterTier::Low));
746 }
747
748 #[test]
749 fn decisive_high() {
750 let code = "x".repeat(600);
751 let text = format!(
752 "Refactor this:\n```rust\nfn main() {{}}\n```\n\nIn src/main.rs and lib/core.rs:\n{code}"
753 );
754 let sig = MessageContentSignal::from_text(&text);
755 assert_eq!(sig.decisive_tier(), Some(RouterTier::High));
756 }
757
758 #[test]
759 fn decisive_none_for_medium() {
760 let sig = MessageContentSignal::from_text("Please update the router config");
761 assert_eq!(sig.decisive_tier(), None);
762 }
763
764 #[test]
767 fn normalized_always_in_bounds() {
768 let inputs = [
769 "",
770 "x",
771 "hello",
772 &"x".repeat(10000),
773 "```python\nprint('hello')\n```",
774 "안녕하세요 세계",
775 ];
776 for input in &inputs {
777 let sig = MessageContentSignal::from_text(input);
778 let n = sig.normalized();
779 assert!((0.0..=1.0).contains(&n), "out of bounds for '{input}': {n}");
780 }
781 }
782}