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            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    // ── Low-tier: short, simple messages ─────────────────────────────
659
660    #[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    // ── Medium-tier: moderate length ─────────────────────────────────
687
688    #[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    // ── High-tier: code, files, technical ─────────────────────────────
705
706    #[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    // ── Extract from messages ────────────────────────────────────────
732
733    #[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); // "update src/main.rs"
741    }
742
743    // ── Decisive tier ────────────────────────────────────────────────
744
745    #[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    // ── Score bounds ─────────────────────────────────────────────────
768
769    #[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}