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 visible: true,
514 })
515 }
516
517 fn image_user_msg() -> Message {
518 Message::User(UserMessage {
519 role: crate::messages::UserRole::User,
520 content: crate::messages::MessageContent::Blocks(vec![ContentBlock::Image(
521 crate::messages::ImageContent {
522 content_type: crate::messages::ImageContentType::Image,
523 data: "fake".to_string(),
524 mime_type: "image/png".to_string(),
525 },
526 )]),
527 timestamp: 0,
528 visible: true,
529 })
530 }
531
532 fn text_tool_result() -> Message {
533 Message::ToolResult(ToolResultMessage {
534 role: crate::messages::ToolResultRole::ToolResult,
535 tool_call_id: "t1".to_string(),
536 tool_name: "bash".to_string(),
537 content: vec![ContentBlock::Text(TextContent {
538 content_type: crate::messages::TextContentType::Text,
539 text: "done".to_string(),
540 text_signature: None,
541 })],
542 details: None,
543 is_error: false,
544 timestamp: 0,
545 })
546 }
547
548 fn image_tool_result(tool: &str) -> Message {
549 Message::ToolResult(ToolResultMessage {
550 role: crate::messages::ToolResultRole::ToolResult,
551 tool_call_id: "t2".to_string(),
552 tool_name: tool.to_string(),
553 content: vec![ContentBlock::Image(crate::messages::ImageContent {
554 content_type: crate::messages::ImageContentType::Image,
555 data: "fake".to_string(),
556 mime_type: "image/png".to_string(),
557 })],
558 details: None,
559 is_error: false,
560 timestamp: 0,
561 })
562 }
563
564 #[test]
565 fn vision_no_images() {
566 let msgs = vec![text_user_msg("hello")];
567 let signal = VisionSignal::extract(&msgs, 10);
568 assert!(!signal.requires_vision());
569 assert_eq!(signal.recent_image_count, 0);
570 }
571
572 #[test]
573 fn vision_user_image() {
574 let msgs = vec![image_user_msg()];
575 let signal = VisionSignal::extract(&msgs, 10);
576 assert!(signal.requires_vision());
577 assert!(signal.has_image_in_latest_turn);
578 }
579
580 #[test]
581 fn vision_tool_result_image() {
582 let msgs = vec![text_user_msg("look"), image_tool_result("browse")];
583 let signal = VisionSignal::extract(&msgs, 10);
584 assert!(signal.requires_vision());
585 assert_eq!(signal.recent_image_count, 1);
586 assert!(signal.image_producing_tools.contains(&"browse".to_string()));
587 }
588
589 #[test]
590 fn vision_browse_screenshot() {
591 let msgs = vec![image_tool_result("browse")];
592 let signal = VisionSignal::extract(&msgs, 10);
593 assert!(signal.requires_vision());
594 assert!(signal.image_producing_tools.contains(&"browse".to_string()));
595 }
596
597 #[test]
598 fn vision_normalized_zero() {
599 let signal = VisionSignal::default();
600 assert!((signal.normalized() - 0.0).abs() < 1e-6);
601 }
602
603 #[test]
604 fn vision_normalized_single() {
605 let signal = VisionSignal {
606 recent_image_count: 1,
607 has_image_in_latest_turn: true,
608 image_producing_tools: vec![],
609 };
610 let n = signal.normalized();
611 assert!(n > 0.5, "single image normalized = {}", n);
612 assert!(n <= 1.0);
613 }
614
615 #[test]
616 fn vision_normalized_multiple() {
617 let signal = VisionSignal {
618 recent_image_count: 3,
619 has_image_in_latest_turn: true,
620 image_producing_tools: vec![],
621 };
622 let n = signal.normalized();
623 assert!(n > 0.9, "3 images normalized = {}", n);
624 }
625
626 #[test]
627 fn vision_window_respected() {
628 let msgs: Vec<Message> = (0..20)
629 .flat_map(|_| vec![text_user_msg("hi"), image_tool_result("browse")])
630 .collect();
631 let signal_full = VisionSignal::extract(&msgs, 100);
632 let signal_windowed = VisionSignal::extract(&msgs, 4);
633 assert!(signal_full.recent_image_count > signal_windowed.recent_image_count);
634 }
635
636 #[test]
637 fn vision_text_only_tool_result() {
638 let msgs = vec![text_user_msg("run"), text_tool_result()];
639 let signal = VisionSignal::extract(&msgs, 10);
640 assert!(!signal.requires_vision());
641 }
642}
643
644#[cfg(test)]
645mod message_content_tests {
646 use super::*;
647 use crate::messages::UserMessage;
648
649 fn text_user_msg(s: &str) -> Message {
650 Message::User(UserMessage {
651 role: crate::messages::UserRole::User,
652 content: crate::messages::MessageContent::Text(s.to_string()),
653 timestamp: 0,
654 visible: true,
655 })
656 }
657
658 #[test]
661 fn msg_empty() {
662 let sig = MessageContentSignal::from_text("");
663 assert!(sig.normalized() < 0.05);
664 }
665
666 #[test]
667 fn msg_greeting() {
668 let sig = MessageContentSignal::from_text("hello");
669 assert!(sig.normalized() < 0.1);
670 assert!(sig.is_single_sentence);
671 }
672
673 #[test]
674 fn msg_korean_greeting() {
675 let sig = MessageContentSignal::from_text("안녕하세요");
676 assert!(sig.normalized() < 0.1);
677 }
678
679 #[test]
680 fn msg_short_question() {
681 let sig = MessageContentSignal::from_text("what is rust?");
682 assert!(sig.normalized() < 0.1);
683 assert!(sig.is_question);
684 }
685
686 #[test]
689 fn msg_moderate() {
690 let sig = MessageContentSignal::from_text(
691 "Modify the config file to add the new endpoint for the auth service",
692 );
693 assert!((0.02..0.25).contains(&sig.normalized()));
694 }
695
696 #[test]
697 fn msg_multiline() {
698 let sig =
699 MessageContentSignal::from_text("I need to update:\n- config\n- router\n- middleware");
700 assert!(sig.normalized() > 0.05);
701 assert_eq!(sig.line_count, 4);
702 }
703
704 #[test]
707 fn msg_code_blocks() {
708 let sig = MessageContentSignal::from_text(
709 "Debug:\n```rust\nfn main() { panic!() }\n```\nStack trace shows null.",
710 );
711 assert!(sig.normalized() > 0.2);
712 assert!(sig.has_code_blocks);
713 }
714
715 #[test]
716 fn msg_multi_file() {
717 let sig =
718 MessageContentSignal::from_text("Update src/main.rs and lib/config.rs for the new API");
719 assert!(sig.file_path_count >= 2);
720 assert!(sig.normalized() > 0.1);
721 }
722
723 #[test]
724 fn msg_high_symbol_density() {
725 let sig = MessageContentSignal::from_text(
726 "{\"type\": \"router\", \"config\": {\"high\": {\"model\": \"opus\"}}}",
727 );
728 assert!(sig.symbol_density > 0.15);
729 }
730
731 #[test]
734 fn extract_from_messages() {
735 let msgs = vec![
736 text_user_msg("system prompt"),
737 text_user_msg("update src/main.rs"),
738 ];
739 let sig = MessageContentSignal::extract(&msgs);
740 assert_eq!(sig.message_length, 18); }
742
743 #[test]
746 fn decisive_low() {
747 let sig = MessageContentSignal::from_text("hi");
748 assert_eq!(sig.decisive_tier(), Some(RouterTier::Low));
749 }
750
751 #[test]
752 fn decisive_high() {
753 let code = "x".repeat(600);
754 let text = format!(
755 "Refactor this:\n```rust\nfn main() {{}}\n```\n\nIn src/main.rs and lib/core.rs:\n{code}"
756 );
757 let sig = MessageContentSignal::from_text(&text);
758 assert_eq!(sig.decisive_tier(), Some(RouterTier::High));
759 }
760
761 #[test]
762 fn decisive_none_for_medium() {
763 let sig = MessageContentSignal::from_text("Please update the router config");
764 assert_eq!(sig.decisive_tier(), None);
765 }
766
767 #[test]
770 fn normalized_always_in_bounds() {
771 let inputs = [
772 "",
773 "x",
774 "hello",
775 &"x".repeat(10000),
776 "```python\nprint('hello')\n```",
777 "안녕하세요 세계",
778 ];
779 for input in &inputs {
780 let sig = MessageContentSignal::from_text(input);
781 let n = sig.normalized();
782 assert!((0.0..=1.0).contains(&n), "out of bounds for '{input}': {n}");
783 }
784 }
785}