Skip to main content

heartbit_core/agent/
router.rs

1//! Request-intent router — picks the RESPONSE MODE a request calls for,
2//! before the first LLM turn (design: `tasks/intent-mode-router-2026-06-07.md`).
3//!
4//! Mid-tier models read hedged requests literally ("je souhaite créer un
5//! petit crm" → unilateral build; Ruis 2210.14986, Korean-ISA 2502.10995:
6//! 84.7% direct vs 58.1% indirect comprehension), so the harness carries the
7//! pragmatic load deterministically: Layer 0 here (marker scan, ~free,
8//! multilingual by enumeration), Layer 1 (`fast`-role LLM) only for the
9//! ambiguous residue, Layer 2 defaults to the SAFER mode on uncertainty.
10//! The model never picks its own mode; the user always can (go-tokens,
11//! pinned mode).
12
13use std::sync::Arc;
14
15use crate::llm::types::{CompletionRequest, ContentBlock, Message};
16use crate::llm::{BoxedProvider, LlmProvider};
17
18/// The response mode a request calls for — force × completeness 2×2
19/// (DAMSL force, ClarEval completeness). See design doc §2.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RequestMode {
22    /// Assertive / info-request, specified → answer in prose, no staging.
23    Answer,
24    /// Directive, specified → act directly (today's behavior).
25    Execute,
26    /// Investigation / under-specified-for-design → read-only; must end in a
27    /// written proposal + go/no-go.
28    Study,
29    /// Under-specified (any force) → ask first (intake/question) before any
30    /// mutation.
31    Clarify,
32}
33
34impl RequestMode {
35    /// Stable label for traces and prompts.
36    pub fn label(self) -> &'static str {
37        match self {
38            RequestMode::Answer => "answer",
39            RequestMode::Execute => "execute",
40            RequestMode::Study => "study",
41            RequestMode::Clarify => "clarify",
42        }
43    }
44
45    /// Parse a label (for pinned modes / classifier replies).
46    pub fn parse(s: &str) -> Option<Self> {
47        match s.trim().to_lowercase().as_str() {
48            "answer" => Some(RequestMode::Answer),
49            "execute" => Some(RequestMode::Execute),
50            "study" => Some(RequestMode::Study),
51            "clarify" => Some(RequestMode::Clarify),
52            _ => None,
53        }
54    }
55}
56
57/// Explicit go-tokens: the user's keypress always wins — EXECUTE when the
58/// go IS the message (see `is_explicit_go`). An occurrence embedded in a
59/// longer sentence is content, not a keypress ("don't do it yet" must not
60/// promote). Checked first.
61const GO_TOKENS: &[&str] = &[
62    "vas-y",
63    "vas y",
64    "fais-le",
65    "fais le",
66    "do it",
67    "just do it",
68    "just build it",
69    "go ahead",
70    "lance-toi",
71    "code directement",
72    "code-le",
73    "implémente directement",
74    "implemente directement",
75];
76
77/// Wish / desiderative force markers (hedged, negotiable requests).
78const WISH_MARKERS: &[&str] = &[
79    "je souhaite",
80    "j'aimerais",
81    "je voudrais",
82    "il faudrait",
83    "ce serait bien",
84    "j'apprécierais",
85    "j'aurais besoin",
86    "i'd like",
87    "i would like",
88    "it would be nice",
89    "could you maybe",
90    "i wish",
91];
92
93/// Investigation force markers → STUDY (propose, no build).
94const STUDY_MARKERS: &[&str] = &[
95    "regarde si",
96    "étudie",
97    "etudie",
98    "analyse",
99    "explore",
100    "réfléchis",
101    "reflechis",
102    "compare les options",
103    "est-ce qu'on peut",
104    "est-ce que l'on peut",
105    "peut-on",
106    "serait-il possible",
107    "is it possible",
108    "can we",
109    "could we",
110    "investigate",
111    "look into",
112    "deep research",
113    "que penses-tu",
114    "what do you think",
115    "évalue",
116    "evalue",
117    // Inflected forms of the study VERBS: word-boundary matching (which
118    // replaced the old substring scan to stop "analyse" firing inside the
119    // noun "analyseur") would otherwise drop the infinitive/imperative
120    // conjugations and let an investigation request fall through to EXECUTE
121    // — the dangerous direction. These forms are NOT substrings of their
122    // noun counterparts (analyseur / évaluateur), so the noun stays out.
123    // `explorer` is deliberately omitted: it is also the English noun.
124    "étudier",
125    "etudier",
126    "étudiez",
127    "etudiez",
128    "analyser",
129    "analysez",
130    "analyze",
131    "évaluer",
132    "evaluer",
133    "évaluez",
134    "evaluez",
135    "réfléchir",
136    "reflechir",
137];
138
139/// Design-heavy nouns that, WITHOUT a concrete spec, signal an
140/// underspecified build request.
141const DESIGN_NOUNS: &[&str] = &[
142    "crm",
143    "app",
144    "application",
145    "site",
146    "dashboard",
147    "api",
148    "service",
149    "bot",
150    "jeu",
151    "game",
152    "outil",
153    "tool",
154    "feature",
155    "fonctionnalité",
156    "fonctionnalite",
157    "module",
158    "système",
159    "systeme",
160    "plateforme",
161    "platform",
162    "interface",
163];
164
165/// Interrogative openers → ANSWER (when not paired with an imperative).
166const QUESTION_OPENERS: &[&str] = &[
167    "que ",
168    "qu'est-ce",
169    "quoi ",
170    "comment ",
171    "pourquoi ",
172    "quel ",
173    "quelle ",
174    "quels ",
175    "quelles ",
176    "what ",
177    "why ",
178    "how ",
179    "which ",
180    "explique",
181    "explain",
182    "c'est quoi",
183];
184
185/// True when the text carries a CONCRETE spec anchor: a path-ish token, a
186/// backticked symbol, or a file extension — the "specified" completeness
187/// signal (a specified wish is an EXECUTE, not a CLARIFY).
188fn has_concrete_anchor(lower: &str) -> bool {
189    if lower.contains('`') {
190        return true;
191    }
192    // path-ish: a token containing '/' with no spaces around the slash, or a
193    // known source-file extension.
194    if lower.split_whitespace().any(|w| {
195        (w.contains('/') && w.len() > 3)
196            || w.ends_with(".rs")
197            || w.ends_with(".py")
198            || w.ends_with(".ts")
199            || w.ends_with(".js")
200            || w.ends_with(".toml")
201            || w.ends_with(".md")
202            || w.ends_with(".json")
203            || w.ends_with(".txt")
204            || w.ends_with(".html")
205            || w.ends_with(".css")
206    }) {
207        return true;
208    }
209    false
210}
211
212/// FR conditional morphology on volition/possibility lemmas — the grammatical
213/// hedge marker ("voudrais", "aimerais", "faudrait", "pourrait-on"…).
214fn has_fr_conditional_volition(lower: &str) -> bool {
215    const STEMS: &[&str] = &[
216        "voudr",
217        "aimer",
218        "faudr",
219        "pourr",
220        "souhaiter",
221        "apprécier",
222        "apprecier",
223    ];
224    lower.split_whitespace().any(|w| {
225        let w = w.trim_matches(|c: char| !c.is_alphanumeric());
226        (w.ends_with("rais") || w.ends_with("rait") || w.ends_with("raient"))
227            && STEMS.iter().any(|s| w.starts_with(s))
228    })
229}
230
231fn contains_any(lower: &str, markers: &[&str]) -> bool {
232    markers.iter().any(|m| lower.contains(m))
233}
234
235/// True when any marker occurs as a whole word/phrase: an occurrence flanked
236/// by an alphanumeric char does not count ("analyse" must not fire inside
237/// "analyseur", "explore" not inside "explorer"). Boundaries are char-class
238/// based (`char::is_alphanumeric`), not ASCII `\b`, so accented French words
239/// bound correctly.
240fn contains_any_word(lower: &str, markers: &[&str]) -> bool {
241    markers.iter().any(|m| contains_word(lower, m))
242}
243
244fn contains_word(lower: &str, marker: &str) -> bool {
245    let mut from = 0;
246    while let Some(pos) = lower[from..].find(marker) {
247        let start = from + pos;
248        let end = start + marker.len();
249        let left_ok = lower[..start]
250            .chars()
251            .next_back()
252            .is_none_or(|c| !c.is_alphanumeric());
253        let right_ok = lower[end..]
254            .chars()
255            .next()
256            .is_none_or(|c| !c.is_alphanumeric());
257        if left_ok && right_ok {
258            return true;
259        }
260        from = end;
261    }
262    false
263}
264
265/// Affirmation filler tolerated around an explicit go ("ok vas-y",
266/// "oui, fais-le alors").
267const GO_FILLER: &[&str] = &[
268    "ok", "oui", "yes", "yeah", "yep", "bon", "alors", "then", "et", "and", "please", "stp", "svp",
269    "d'accord", "parfait",
270];
271
272/// True when the trimmed lowercase message IS an explicit go: every word is
273/// part of a GO_TOKEN phrase or affirmation filler (punctuation-tolerant),
274/// with at least one go-token present. A go-token embedded in a longer
275/// sentence ("don't do it yet", "before you go ahead…", "tu vas y arriver")
276/// must NOT force EXECUTE — the mode with no safety nets.
277fn is_explicit_go(trimmed: &str) -> bool {
278    // Normalize: keep word chars (incl. accents), hyphens and apostrophes;
279    // everything else (punctuation, dashes, nbsp…) becomes a separator.
280    let normalized: String = trimmed
281        .chars()
282        .map(|c| {
283            if c.is_alphanumeric() || c == '-' || c == '\'' {
284                c
285            } else {
286                ' '
287            }
288        })
289        .collect();
290    let words: Vec<&str> = normalized.split_whitespace().collect();
291    if words.is_empty() {
292        return false;
293    }
294    let go_phrases: Vec<Vec<&str>> = GO_TOKENS
295        .iter()
296        .map(|t| t.split_whitespace().collect())
297        .collect();
298    let mut i = 0;
299    let mut saw_go = false;
300    'words: while i < words.len() {
301        for phrase in &go_phrases {
302            if words[i..].starts_with(phrase) {
303                i += phrase.len();
304                saw_go = true;
305                continue 'words;
306            }
307        }
308        if GO_FILLER.contains(&words[i]) {
309            i += 1;
310            continue;
311        }
312        return false;
313    }
314    saw_go
315}
316
317/// Layer 0: deterministic classification. Returns `Some(mode)` only when
318/// CONFIDENT; `None` = ambiguous residue (Layer 1 / safe default).
319pub fn classify_l0(text: &str) -> Option<RequestMode> {
320    let lower = text.to_lowercase();
321    let trimmed = lower.trim();
322
323    // The user's explicit go always wins — but only when the go IS the
324    // message (whole-text token equality, punctuation/filler tolerated),
325    // never as a substring of a longer request ("don't do it yet…").
326    if is_explicit_go(trimmed) {
327        return Some(RequestMode::Execute);
328    }
329
330    let wish = contains_any(trimmed, WISH_MARKERS) || has_fr_conditional_volition(trimmed);
331    let study = contains_any_word(trimmed, STUDY_MARKERS);
332    let design = contains_any(trimmed, DESIGN_NOUNS);
333    let anchored = has_concrete_anchor(trimmed);
334    let interrogative = QUESTION_OPENERS.iter().any(|q| trimmed.starts_with(q))
335        || (trimmed.ends_with('?') && !wish && !design);
336
337    // Investigation request → STUDY (checked BEFORE the interrogative
338    // branch: "est-ce qu'on peut paralléliser ?" / "can we speed up X?" are
339    // feasibility studies phrased as questions, not info requests).
340    if study {
341        return Some(RequestMode::Study);
342    }
343    // Pure information request → ANSWER.
344    if interrogative && !design {
345        return Some(RequestMode::Answer);
346    }
347    // Hedged force…
348    if wish {
349        // …with a concrete spec → the wish IS the directive (specified wish).
350        if anchored && !design {
351            return Some(RequestMode::Execute);
352        }
353        // …around a design-heavy object with no spec → the incident: CLARIFY.
354        if design && !anchored {
355            return Some(RequestMode::Clarify);
356        }
357        // hedged but neither anchored nor design-heavy → ambiguous.
358        return None;
359    }
360    // Direct imperative…
361    if design && !anchored {
362        // …on a design noun with no spec → underspecified imperative: CLARIFY.
363        return Some(RequestMode::Clarify);
364    }
365    if anchored {
366        // …on a concrete artifact → EXECUTE.
367        return Some(RequestMode::Execute);
368    }
369    None
370}
371
372/// Bare affirmation / continuation ("ok vas-y", "oui", "go") — a SHORT
373/// message with no new substantive content. The follow-up policy (§6)
374/// promotes the prior STUDY/CLARIFY plan to EXECUTE instead of re-routing.
375pub fn is_bare_affirmation(text: &str) -> bool {
376    let lower = text.trim().to_lowercase();
377    if lower.chars().count() > 40 {
378        return false;
379    }
380    const AFFIRMATIONS: &[&str] = &[
381        "ok",
382        "oui",
383        "yes",
384        "go",
385        "vas-y",
386        "vas y",
387        "fais-le",
388        "fais le",
389        "do it",
390        "continue",
391        "d'accord",
392        "daccord",
393        "parfait",
394        "ça marche",
395        "ca marche",
396        "allons-y",
397        "allons y",
398        "let's go",
399        "lets go",
400        "lance",
401        "ok vas-y",
402        "yep",
403        "yeah",
404        "sure",
405        "proceed",
406    ];
407    // Every word-ish chunk must be affirmation-ish (allow punctuation).
408    let stripped: String = lower
409        .chars()
410        .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '\'')
411        .collect();
412    let s = stripped.trim();
413    if s.is_empty() {
414        return false;
415    }
416    AFFIRMATIONS.contains(&s)
417        || s.split_whitespace()
418            .all(|w| AFFIRMATIONS.contains(&w) || matches!(w, "et" | "and" | "alors" | "then"))
419}
420
421/// System prompt for the Layer-1 classifier (the `fast` role).
422const CLASSIFIER_SYSTEM: &str = "\
423You classify a user request sent to a coding agent into the RESPONSE MODE it \
424calls for. Reply with STRICT JSON only: {\"mode\":\"answer|execute|study|clarify\",\
425\"confidence\":0.0-1.0}.\n\
426- answer: an information question; no work requested.\n\
427- execute: a directive with a clear, specified target — act now.\n\
428- study: an investigation/feasibility/comparison request — propose, don't build.\n\
429- clarify: work is requested but key design choices are unspecified (what kind, \
430which interface, what data) — ask before building.\n\
431Judge the ILLOCUTIONARY FORCE (a hedged wish like \"je souhaite…\" over an \
432unspecified object is clarify, not execute) and the COMPLETENESS of the spec.";
433
434/// Confidence below which Layer 2 falls back to the safer mode.
435const CONFIDENCE_THRESHOLD: f32 = 0.6;
436
437/// The request-intent router: Layer 0 markers + optional Layer-1 `fast`
438/// classifier + Layer-2 safe default + an optional user-pinned mode that
439/// overrides everything (the user always wins; the model never does).
440pub struct RequestRouter {
441    fast: Option<Arc<BoxedProvider>>,
442    /// 0 = auto; 1 = answer; 2 = execute; 3 = study; 4 = clarify.
443    pin: Option<Arc<std::sync::atomic::AtomicU8>>,
444}
445
446impl RequestMode {
447    /// Pin encoding (0 = auto/unpinned).
448    pub fn as_pin_u8(self) -> u8 {
449        match self {
450            RequestMode::Answer => 1,
451            RequestMode::Execute => 2,
452            RequestMode::Study => 3,
453            RequestMode::Clarify => 4,
454        }
455    }
456
457    /// Decode a pin value (`None` for 0/unknown = auto).
458    pub fn from_pin_u8(v: u8) -> Option<Self> {
459        match v {
460            1 => Some(RequestMode::Answer),
461            2 => Some(RequestMode::Execute),
462            3 => Some(RequestMode::Study),
463            4 => Some(RequestMode::Clarify),
464            _ => None,
465        }
466    }
467}
468
469impl RequestRouter {
470    /// `fast`: the cheap classifier provider (Layer 1). `None` = degraded
471    /// path — Layer 0 + safe default hold the line alone (design O1).
472    pub fn new(fast: Option<Arc<BoxedProvider>>) -> Self {
473        Self { fast, pin: None }
474    }
475
476    /// Share a user-pinned-mode cell (e.g. the TUI `/mode study` command):
477    /// a non-zero pin short-circuits routing entirely.
478    pub fn with_pin(mut self, pin: Arc<std::sync::atomic::AtomicU8>) -> Self {
479        self.pin = Some(pin);
480        self
481    }
482
483    /// The currently pinned mode, if the user pinned one (`None` = auto).
484    /// The runner consults this before the bare-affirmation promotion: a
485    /// PINNED Study/Clarify mode must never be silently promoted to Execute.
486    pub fn pinned_mode(&self) -> Option<RequestMode> {
487        self.pin
488            .as_ref()
489            .and_then(|p| RequestMode::from_pin_u8(p.load(std::sync::atomic::Ordering::Relaxed)))
490    }
491
492    /// Route a fresh request. Always returns a mode (never blocks the run):
493    /// L0 when confident, else L1 when available and confident, else the
494    /// SAFER default (CLARIFY for design-ish texts, STUDY otherwise).
495    pub async fn route(&self, text: &str) -> RoutedMode {
496        if let Some(pin) = &self.pin
497            && let Some(mode) =
498                RequestMode::from_pin_u8(pin.load(std::sync::atomic::Ordering::Relaxed))
499        {
500            return RoutedMode {
501                mode,
502                source: RouteSource::Pinned,
503                confidence: 1.0,
504            };
505        }
506        if let Some(mode) = classify_l0(text) {
507            return RoutedMode {
508                mode,
509                source: RouteSource::Markers,
510                confidence: 1.0,
511            };
512        }
513        if let Some(fast) = &self.fast
514            && let Some((mode, confidence)) = self.classify_l1(fast, text).await
515            && confidence >= CONFIDENCE_THRESHOLD
516        {
517            return RoutedMode {
518                mode,
519                source: RouteSource::Classifier,
520                confidence,
521            };
522        }
523        // Layer 2: safe default. A wrong guess toward "ask/propose" costs one
524        // round-trip; a wrong guess toward "act" costs unwanted writes.
525        let lower = text.to_lowercase();
526        let mode = if contains_any(&lower, DESIGN_NOUNS) {
527            RequestMode::Clarify
528        } else {
529            RequestMode::Study
530        };
531        RoutedMode {
532            mode,
533            source: RouteSource::SafeDefault,
534            confidence: 0.0,
535        }
536    }
537
538    async fn classify_l1(
539        &self,
540        fast: &Arc<BoxedProvider>,
541        text: &str,
542    ) -> Option<(RequestMode, f32)> {
543        let request = CompletionRequest {
544            system: CLASSIFIER_SYSTEM.to_string(),
545            messages: vec![Message::user(format!("REQUEST:\n{text}"))],
546            tools: Vec::new(),
547            max_tokens: 64,
548            tool_choice: None,
549            reasoning_effort: None,
550        };
551        let response = fast.complete(request).await.ok()?;
552        let body: String = response
553            .content
554            .iter()
555            .filter_map(|b| match b {
556                ContentBlock::Text { text } => Some(text.as_str()),
557                _ => None,
558            })
559            .collect();
560        // Tolerant parse: find the outermost JSON object. A '}' preceding
561        // the first '{' (stray-brace reply) must bail, not panic the slice.
562        let start = body.find('{')?;
563        let end = body.rfind('}')?;
564        if end <= start {
565            return None;
566        }
567        let v: serde_json::Value = serde_json::from_str(&body[start..=end]).ok()?;
568        let mode = RequestMode::parse(v.get("mode")?.as_str()?)?;
569        let confidence = v.get("confidence")?.as_f64().unwrap_or(0.0) as f32;
570        Some((mode, confidence))
571    }
572}
573
574/// A routing decision plus its provenance (for traces and tests).
575#[derive(Debug, Clone, Copy, PartialEq)]
576pub struct RoutedMode {
577    /// The chosen response mode.
578    pub mode: RequestMode,
579    /// Which layer decided.
580    pub source: RouteSource,
581    /// Classifier confidence (1.0 for markers, 0.0 for the safe default).
582    pub confidence: f32,
583}
584
585/// Which router layer produced the decision.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub enum RouteSource {
588    /// Layer 0 — deterministic markers.
589    Markers,
590    /// Layer 1 — `fast`-role LLM classifier.
591    Classifier,
592    /// Layer 2 — low confidence / no classifier → safer mode.
593    SafeDefault,
594    /// User-pinned mode (`/mode study` etc.) — overrides routing.
595    Pinned,
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    /// P1 — the labeled NATIVE fixture set (design doc §8, ship blocker).
603    /// Every later router change must keep this table green.
604    const L0_FIXTURES: &[(&str, RequestMode)] = &[
605        // --- the incident class: hedged wish over a design noun → CLARIFY
606        (
607            "je souhaite créer un petit crm dans un répertoire temporaire",
608            RequestMode::Clarify,
609        ),
610        ("j'aimerais une petite app de notes", RequestMode::Clarify),
611        (
612            "je voudrais un dashboard pour suivre mes ventes",
613            RequestMode::Clarify,
614        ),
615        ("il faudrait un outil de migration", RequestMode::Clarify),
616        ("i'd like a small crm for my contacts", RequestMode::Clarify),
617        // --- underspecified IMPERATIVE → CLARIFY (the conflation bug: no wish marker)
618        ("construis-moi un crm", RequestMode::Clarify),
619        ("crée une app de todo", RequestMode::Clarify),
620        ("build me a dashboard", RequestMode::Clarify),
621        // --- specified WISH → EXECUTE (the other half of the conflation bug)
622        (
623            "j'aimerais que tu renommes la fonction `foo` en `bar`",
624            RequestMode::Execute,
625        ),
626        (
627            "je voudrais corriger la typo dans src/main.rs",
628            RequestMode::Execute,
629        ),
630        (
631            "i'd like you to fix the import in lib.rs",
632            RequestMode::Execute,
633        ),
634        // --- directive + concrete anchor → EXECUTE
635        ("renomme foo en bar dans src/app.rs", RequestMode::Execute),
636        ("corrige la typo dans README.md", RequestMode::Execute),
637        (
638            "ajoute un test dans crates/core/tests.rs",
639            RequestMode::Execute,
640        ),
641        // --- go-tokens promote ONLY when the go IS the whole message
642        // (token equality modulo punctuation/filler — audit 2026-06-09)
643        ("vas-y", RequestMode::Execute),
644        ("ok vas-y !", RequestMode::Execute),
645        ("just do it", RequestMode::Execute),
646        ("go ahead", RequestMode::Execute),
647        // --- an EMBEDDED go-token is content, not a keypress: route on the
648        // rest of the text. These two previously expected EXECUTE via the
649        // whole-text substring bug; design-noun-without-spec is CLARIFY.
650        ("vas-y code directement le crm", RequestMode::Clarify),
651        ("je souhaite un crm — just build it", RequestMode::Clarify),
652        // --- STUDY stems must not fire inside larger words (audit 2026-06-09)
653        (
654            "corrige l'analyseur dans src/parse.rs",
655            RequestMode::Execute,
656        ),
657        (
658            "add tests for the explorer view in src/ui.rs",
659            RequestMode::Execute,
660        ),
661        ("analyse les options de cache", RequestMode::Study),
662        // --- investigation → STUDY
663        ("regarde si on peut accélérer le build", RequestMode::Study),
664        (
665            "étudie les options de persistance pour le module",
666            RequestMode::Study,
667        ),
668        (
669            "est-ce qu'on peut paralléliser les tests ?",
670            RequestMode::Study,
671        ),
672        ("can we speed up the ci pipeline?", RequestMode::Study),
673        (
674            "que penses-tu d'une migration vers axum ?",
675            RequestMode::Study,
676        ),
677        // --- information questions → ANSWER
678        ("que sais-tu faire ?", RequestMode::Answer),
679        ("comment fonctionne le scheduler ?", RequestMode::Answer),
680        ("explique-moi le rôle du pruner", RequestMode::Answer),
681        ("what does the doom loop detector do?", RequestMode::Answer),
682        ("pourquoi le build est-il si lent ?", RequestMode::Answer),
683    ];
684
685    #[test]
686    fn l0_fixture_set_routes_native_requests() {
687        let mut failures = Vec::new();
688        for (text, expected) in L0_FIXTURES {
689            match classify_l0(text) {
690                Some(mode) if mode == *expected => {}
691                got => failures.push(format!("{text:?} → {got:?}, expected {expected:?}")),
692            }
693        }
694        assert!(
695            failures.is_empty(),
696            "L0 misroutes ({}/{}):\n{}",
697            failures.len(),
698            L0_FIXTURES.len(),
699            failures.join("\n")
700        );
701    }
702
703    /// Ambiguous residue: L0 must NOT guess — these return None (→ L1/L2).
704    const L0_AMBIGUOUS: &[&str] = &[
705        "je souhaite améliorer les choses",
706        "fais quelque chose de bien",
707        "on pourrait faire mieux",
708        "help me with the project",
709    ];
710
711    #[test]
712    fn l0_does_not_guess_on_ambiguity() {
713        for text in L0_AMBIGUOUS {
714            assert_eq!(
715                classify_l0(text),
716                None,
717                "{text:?} must fall through to L1/L2"
718            );
719        }
720    }
721
722    /// Finding 2 (audit 2026-06-09): a go-token promotes only when the go
723    /// IS the entire trimmed message (punctuation + affirmation filler
724    /// tolerated), never as a substring of a longer request.
725    #[test]
726    fn explicit_go_requires_the_whole_message() {
727        for s in [
728            "vas-y",
729            "Vas-y !",
730            "ok vas-y",
731            "fais-le.",
732            "do it",
733            "go ahead",
734            "oui, fais-le alors",
735            "vas y",
736        ] {
737            assert_eq!(
738                classify_l0(s),
739                Some(RequestMode::Execute),
740                "{s:?} is an explicit go"
741            );
742        }
743    }
744
745    #[test]
746    fn embedded_or_negated_go_does_not_promote() {
747        for s in [
748            "don't do it yet, first show me the plan",
749            "before you go ahead, explain the tradeoffs",
750            "tu vas y arriver",
751            "ne le fais pas tout de suite, vas-y étape par étape",
752        ] {
753            assert_ne!(
754                classify_l0(s),
755                Some(RequestMode::Execute),
756                "{s:?} must NOT promote to EXECUTE"
757            );
758        }
759    }
760
761    /// Finding 3 (audit 2026-06-09): STUDY stems must match on
762    /// char-class word boundaries — "analyse" must not fire inside
763    /// "analyseur", "explore" not inside "explorer".
764    #[test]
765    fn study_stems_do_not_match_inside_words() {
766        for s in [
767            "corrige l'analyseur dans src/parse.rs",
768            "add tests for the explorer view in src/ui.rs",
769            "améliore l'exploitation des logs dans src/log.rs",
770        ] {
771            assert_eq!(
772                classify_l0(s),
773                Some(RequestMode::Execute),
774                "{s:?} is an anchored directive, not a study"
775            );
776        }
777        // …while genuine study verbs at word boundaries still route STUDY.
778        for s in [
779            "analyse les options de cache",
780            "explore les alternatives à kafka",
781        ] {
782            assert_eq!(
783                classify_l0(s),
784                Some(RequestMode::Study),
785                "{s:?} is a study request"
786            );
787        }
788    }
789
790    /// Regression (audit 2026-06-09): word-boundary matching must still catch
791    /// INFLECTED study verbs (infinitive/imperative). With only bare stems an
792    /// "analyser …/etudier …/évaluer …" request carrying a concrete code
793    /// anchor (a path/extension) silently fell through to EXECUTE — the
794    /// safety-protected STUDY→EXECUTE flip. The noun forms must stay EXECUTE.
795    #[test]
796    fn inflected_study_verbs_with_anchor_route_study_not_execute() {
797        for s in [
798            "analyser le module dans src/cache.rs",
799            "étudier l'option de persistance dans src/db.rs",
800            "évaluer les alternatives à kafka dans src/bus.rs",
801            "analysez le coût du parsing dans src/parse.rs",
802        ] {
803            assert_eq!(
804                classify_l0(s),
805                Some(RequestMode::Study),
806                "{s:?} is an inflected study verb — must route STUDY, never EXECUTE"
807            );
808        }
809        // The noun counterparts remain anchored directives (EXECUTE): the
810        // inflected verbs are not substrings of these.
811        for s in [
812            "corrige l'analyseur dans src/parse.rs",
813            "répare l'évaluateur d'expressions dans src/eval.rs",
814        ] {
815            assert_eq!(
816                classify_l0(s),
817                Some(RequestMode::Execute),
818                "{s:?} names a noun, not a study verb"
819            );
820        }
821    }
822
823    #[test]
824    fn bare_affirmations_detected_long_content_not() {
825        for s in ["ok", "vas-y", "ok vas-y", "oui continue", "go", "d'accord"] {
826            assert!(is_bare_affirmation(s), "{s:?} is a bare affirmation");
827        }
828        for s in [
829            "vas-y mais utilise plutôt sqlite et ajoute une api rest",
830            "ok mais d'abord explique-moi le schéma",
831            "",
832        ] {
833            assert!(!is_bare_affirmation(s), "{s:?} is NOT bare");
834        }
835    }
836
837    #[test]
838    fn mode_labels_roundtrip() {
839        for m in [
840            RequestMode::Answer,
841            RequestMode::Execute,
842            RequestMode::Study,
843            RequestMode::Clarify,
844        ] {
845            assert_eq!(RequestMode::parse(m.label()), Some(m));
846        }
847        assert_eq!(RequestMode::parse("nonsense"), None);
848    }
849
850    // --- Layer 1 + Layer 2 (mock provider) ---
851
852    struct StubFast {
853        reply: String,
854    }
855    impl LlmProvider for StubFast {
856        async fn complete(
857            &self,
858            _r: CompletionRequest,
859        ) -> Result<crate::llm::types::CompletionResponse, crate::error::Error> {
860            Ok(crate::llm::types::CompletionResponse {
861                content: vec![ContentBlock::Text {
862                    text: self.reply.clone(),
863                }],
864                stop_reason: crate::llm::types::StopReason::EndTurn,
865                reasoning: None,
866                usage: crate::llm::types::TokenUsage::default(),
867                model: None,
868            })
869        }
870    }
871
872    fn router_with(reply: &str) -> RequestRouter {
873        RequestRouter::new(Some(Arc::new(BoxedProvider::from_arc(Arc::new(
874            StubFast {
875                reply: reply.into(),
876            },
877        )))))
878    }
879
880    #[tokio::test]
881    async fn pinned_mode_overrides_routing() {
882        let pin = Arc::new(std::sync::atomic::AtomicU8::new(
883            RequestMode::Study.as_pin_u8(),
884        ));
885        let r = RequestRouter::new(None).with_pin(pin.clone());
886        // Even a crystal-clear EXECUTE request obeys the pin.
887        let routed = r.route("corrige la typo dans src/a.rs").await;
888        assert_eq!(routed.mode, RequestMode::Study);
889        assert_eq!(routed.source, RouteSource::Pinned);
890        // Pin back to auto → normal routing resumes.
891        pin.store(0, std::sync::atomic::Ordering::Relaxed);
892        let routed = r.route("corrige la typo dans src/a.rs").await;
893        assert_eq!(routed.mode, RequestMode::Execute);
894    }
895
896    #[tokio::test]
897    async fn l1_classifier_routes_the_ambiguous_residue() {
898        let r = router_with(r#"{"mode":"study","confidence":0.9}"#);
899        let routed = r.route("je souhaite améliorer les choses").await;
900        assert_eq!(routed.mode, RequestMode::Study);
901        assert_eq!(routed.source, RouteSource::Classifier);
902    }
903
904    #[tokio::test]
905    async fn low_confidence_falls_back_to_safe_default() {
906        let r = router_with(r#"{"mode":"execute","confidence":0.3}"#);
907        let routed = r.route("fais quelque chose de bien").await;
908        assert_eq!(routed.source, RouteSource::SafeDefault);
909        assert_eq!(routed.mode, RequestMode::Study, "non-design text → STUDY");
910    }
911
912    #[tokio::test]
913    async fn degraded_no_fast_path_safe_defaults() {
914        let r = RequestRouter::new(None);
915        // wish + design noun + concrete anchor = the genuinely ambiguous
916        // shape (L0 returns None: specified-wish says EXECUTE, design says
917        // CLARIFY) → degraded path must safe-default on the design signal.
918        let routed = r
919            .route("je souhaite un crm comme décrit dans specs/crm.md")
920            .await;
921        assert_eq!(routed.source, RouteSource::SafeDefault);
922        assert_eq!(
923            routed.mode,
924            RequestMode::Clarify,
925            "design-ish ambiguous text → CLARIFY"
926        );
927    }
928
929    /// Finding 1 (audit 2026-06-09): '}' preceding the only '{' in the
930    /// classifier reply (end < start) must bail to the safe default, not
931    /// panic the routing thread on a reversed slice.
932    #[tokio::test]
933    async fn classifier_reply_with_brace_before_open_does_not_panic() {
934        let r = router_with("nope} I cannot classify this {");
935        let routed = r.route("fais quelque chose de bien").await;
936        assert_eq!(routed.source, RouteSource::SafeDefault);
937    }
938
939    #[tokio::test]
940    async fn garbage_classifier_reply_safe_defaults() {
941        let r = router_with("i refuse to answer in json");
942        let routed = r.route("fais quelque chose de bien").await;
943        assert_eq!(routed.source, RouteSource::SafeDefault);
944    }
945
946    #[tokio::test]
947    async fn l0_confident_skips_the_classifier() {
948        // Even with a classifier that would say EXECUTE, a wish+design-noun
949        // request routes CLARIFY at L0 (markers win; the model can't promote).
950        let r = router_with(r#"{"mode":"execute","confidence":0.99}"#);
951        let routed = r.route("je souhaite créer un petit crm").await;
952        assert_eq!(routed.mode, RequestMode::Clarify);
953        assert_eq!(routed.source, RouteSource::Markers);
954    }
955}