Skip to main content

oxicode_ai/router/
signals.rs

1//! Signal extraction for routing decisions.
2
3#![allow(missing_docs)]
4
5//! Four signal types are extracted from the conversation context:
6//! - **StructuralSignal**: message count, tool-call density, context size.
7//! - **BehavioralSignal**: conversation phase, recent tool-use patterns.
8//! - **ContextBudgetSignal**: token-budget pressure and cost constraints.
9//! - **VisionSignal**: image content requiring vision-capable models.
10
11use crate::messages::{ContentBlock, Message};
12
13use super::types::{RouterPhase, RouterTier, RoutingDecision};
14
15// ── Structural Signal ─────────────────────────────────────────────────────────
16
17/// Signals derived from the structure of the conversation.
18#[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    /// Extract structural signals from the conversation messages.
29    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    /// Normalize to `[0, 1]` for scoring.
79    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// ── Behavioral Signal ─────────────────────────────────────────────────────────
88
89/// Signals derived from recent conversation behavior.
90#[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    /// Extract behavioral signals from context and decision history.
100    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    /// Count tool-result messages in the last `n` messages.
145    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    /// Count phase changes in the decision history.
154    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    /// Normalize to `[0, 1]` for scoring.
186    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// ── Context / Budget Signal ───────────────────────────────────────────────────
195
196/// Signals derived from token budget and cost constraints.
197#[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    /// Extract budget signals from token estimate, cost, and config.
207    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    /// Returns `true` if context length exceeds the upgrade threshold.
222    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    /// Returns `true` if accumulated cost exceeds budget.
229    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    /// Budget utilization ratio `[0, 1]` (1.0 = at/over limit).
236    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    /// Normalize to `[0, 1]` for scoring (higher = more resource pressure).
243    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// ── Vision Signal ──────────────────────────────────────────────────────────────
251
252/// Signal derived from image content in the conversation.
253///
254/// Detects image blocks in user messages and tool results (e.g. screenshots)
255/// to determine whether a vision-capable model is needed.
256#[derive(Debug, Clone, Default)]
257pub struct VisionSignal {
258    /// Number of image blocks found in the recent window.
259    pub recent_image_count: usize,
260    /// Whether the latest user turn contains an image.
261    pub has_image_in_latest_turn: bool,
262    /// Tool names that produced images (e.g. "browse", "browse_script").
263    pub image_producing_tools: Vec<String>,
264}
265
266impl VisionSignal {
267    /// Extract vision signal from the last `window` messages.
268    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        // Check if the latest user message has images
275        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; // Only check the last user message
285            }
286        }
287
288        // Count images in recent window and identify source tools
289        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                        // Track which tool produced the image
308                        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    /// Whether any image content requires a vision-capable model.
321    pub fn requires_vision(&self) -> bool {
322        self.recent_image_count > 0 || self.has_image_in_latest_turn
323    }
324
325    /// Normalize to `[0, 1]` for scoring.
326    ///
327    /// - 0 images → 0.0 (no effect)
328    /// - 1 image → 0.7
329    /// - 2+ images → 0.9 → approaching 1.0
330    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        // Sharper curve: 1→0.7, 2→0.9, 3→0.95, 4+→~1.0
338        1.0 - (-0.8 * count as f64).exp()
339    }
340}
341
342// ── Message Content Signal ────────────────────────────────────────────────────
343
344/// Signal derived from the **structural** properties of the last user message.
345///
346/// Language-agnostic — measures length, line count, code blocks, file paths,
347/// symbol density, etc. No keyword matching.
348#[derive(Debug, Clone, Default)]
349pub struct MessageContentSignal {
350    /// Character count of the last user message.
351    pub message_length: usize,
352    /// Number of lines.
353    pub line_count: usize,
354    /// Whether the message contains code fences (``` ```).
355    pub has_code_blocks: bool,
356    /// Number of distinct file path references detected.
357    pub file_path_count: usize,
358    /// Ratio of code-like symbols to total characters.
359    pub symbol_density: f64,
360    /// Whether the message ends with '?'.
361    pub is_question: bool,
362    /// Whether it's a short single-sentence (≤3 words, no newlines).
363    pub is_single_sentence: bool,
364}
365
366impl MessageContentSignal {
367    /// Extract from the last user message in the conversation.
368    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    /// Analyze a raw text string.
381    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        // Count file paths: /foo/bar.rs or \foo\bar.rs
388        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        // Symbol density
405        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    /// Normalize to `[0, 1]` for scoring.
429    ///
430    /// Contributions:
431    /// - length: 0–0.25
432    /// - line count: 0–0.15
433    /// - code blocks: 0 or 0.15
434    /// - file paths: 0–0.15
435    /// - symbol density: 0–0.15
436    /// - down-weight: question/short → −0.08/−0.06
437    pub fn normalized(&self) -> f64 {
438        let mut score = 0.0;
439
440        // Length
441        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        // Line count
451        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        // Code blocks
459        if self.has_code_blocks {
460            score += 0.15;
461        }
462
463        // File paths
464        score += (0.05 * self.file_path_count.min(3) as f64).min(0.15);
465
466        // Symbol density
467        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        // Down-weight simple patterns
475        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    /// Quick override: returns `Some(tier)` if the signal is decisive enough
486    /// to skip the full scoring pipeline.
487    ///
488    /// Used by Layer 0 ("확실한가?") — only triggers when the signal is
489    /// overwhelmingly simple or complex.
490    pub fn decisive_tier(&self) -> Option<RouterTier> {
491        // Very short, no structure → definitely low
492        if self.message_length < 15 && self.is_single_sentence && !self.has_code_blocks {
493            return Some(RouterTier::Low);
494        }
495        // Long + code blocks + file paths → definitely high
496        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    // ── Low-tier: short, simple messages ─────────────────────────────
656
657    #[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    // ── Medium-tier: moderate length ─────────────────────────────────
684
685    #[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    // ── High-tier: code, files, technical ─────────────────────────────
702
703    #[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    // ── Extract from messages ────────────────────────────────────────
729
730    #[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); // "update src/main.rs"
738    }
739
740    // ── Decisive tier ────────────────────────────────────────────────
741
742    #[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    // ── Score bounds ─────────────────────────────────────────────────
765
766    #[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}