Skip to main content

jev_harness/
client.rs

1//! HTTP client and offline deterministic simulation engine for TypeSafe Jev System One.
2
3use crate::config::load_repo_config;
4use crate::types::*;
5use std::collections::{HashMap, HashSet};
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9use std::sync::LazyLock;
10use std::time::Duration;
11
12pub const DEFAULT_MODEL: &str = "jev-latest";
13pub const DEFAULT_TIMEOUT_MS: u64 = 10000;
14pub const TYPESAFE_API_URL: &str = "https://api.typesafe.ai/v1/systemone";
15pub const COMMANDCODE_API_URL: &str = "https://api.commandcode.ai/provider/v1/systemone";
16pub const OPENCODE_API_URL: &str = "https://opencode.ai/zen/v1/systemone";
17pub const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/alpha/decisions";
18pub const VERCEL_API_URL: &str = "https://ai-gateway.vercel.sh/v1/evaluate";
19pub const DEFAULT_USER_AGENT: &str = concat!(
20    "Mozilla/5.0 (compatible; JevHarness/",
21    env!("CARGO_PKG_VERSION"),
22    "; +https://github.com/ismaelsoilet/jev-harness)"
23);
24
25/// E3.5 — masks credential-shaped material before a log is sent to a provider.
26/// Mirrors `redact_secrets` in Python/TypeScript: all three runtimes can transmit a failure log,
27/// so all three must mask the same shapes.
28static SECRET_PATTERNS: LazyLock<Vec<(&'static str, regex::Regex)>> = LazyLock::new(|| {
29    [
30        (
31            "group",
32            r#"(?i)\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd|client[_-]?secret|private[_-]?key|bearer)\b\s*[:=]\s*["']?([A-Za-z0-9._\-/+]{6,})["']?"#,
33        ),
34        ("plain", r"(?i)\b(?:sk|pk|rk|vck|xox[baprs])[-_][A-Za-z0-9._\-]{12,}"),
35        ("plain", r"(?i)\bgh[pousr]_[A-Za-z0-9]{20,}"),
36        ("plain", r"\bAKIA[0-9A-Z]{16}\b"),
37        ("key", r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----"),
38        ("plain", r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
39        ("url", r"(?i)((?:postgres|mysql|mongodb|redis)(?:\+\w+)?)://[^\s:@/]+:[^\s@/]+@"),
40    ]
41    .iter()
42    .map(|(kind, pattern)| (*kind, regex::Regex::new(pattern).expect("secret pattern")))
43    .collect()
44});
45
46pub fn redact_secrets(text: &str) -> String {
47    if text.is_empty() {
48        return String::new();
49    }
50    let mut redacted = text.to_string();
51    for (kind, pattern) in SECRET_PATTERNS.iter() {
52        redacted = match *kind {
53            "group" => pattern
54                .replace_all(&redacted, |caps: &regex::Captures<'_>| match caps.get(1) {
55                    Some(value) => caps[0].replace(value.as_str(), "[REDACTED]"),
56                    None => "[REDACTED]".to_string(),
57                })
58                .to_string(),
59            "key" => pattern
60                .replace_all(&redacted, "[REDACTED PRIVATE KEY]")
61                .to_string(),
62            "url" => pattern
63                .replace_all(&redacted, "${1}://[REDACTED]@")
64                .to_string(),
65            _ => pattern.replace_all(&redacted, "[REDACTED]").to_string(),
66        };
67    }
68    redacted
69}
70
71/// Renders a structured state (E0.4) as labelled text with **real newlines**.
72///
73/// The wire keeps the JSON form (structure + path references), but the offline engine is
74/// line-oriented: JSON escapes every newline, which would collapse a multi-line log into a single
75/// line and silently change every line-anchored pattern. Mirrors `render_state_text` in
76/// Python/TypeScript so the three runtimes score the same text.
77pub fn render_state_text(state: &str) -> String {
78    let trimmed = state.trim_start();
79    if !trimmed.starts_with('{') {
80        return state.to_string();
81    }
82    let parsed: serde_json::Value = match serde_json::from_str(trimmed) {
83        Ok(value) => value,
84        Err(_) => return state.to_string(),
85    };
86    let object = match parsed.as_object() {
87        Some(map) => map,
88        None => return state.to_string(),
89    };
90    let mut parts: Vec<String> = Vec::new();
91    for (key, value) in object {
92        // Perception fields are provider-facing metadata (E3.5): the offline engine keeps scoring
93        // the full log, so its verdicts stay comparable across runtimes.
94        if matches!(
95            key.as_str(),
96            "focused_slice" | "causal_context" | "raw_log_ref"
97        ) {
98            continue;
99        }
100        let rendered = match value {
101            serde_json::Value::String(text) => text.clone(),
102            other => other.to_string(),
103        };
104        parts.push(format!("{key}:\n{rendered}"));
105    }
106    parts.join("\n\n")
107}
108
109/// Builds the structured state for one gate, dropping empty fields (parity with Python/TS).
110pub fn build_state(fields: serde_json::Map<String, serde_json::Value>) -> String {
111    let mut state = serde_json::Map::new();
112    for (key, value) in fields {
113        let empty = match &value {
114            serde_json::Value::Null => true,
115            serde_json::Value::String(text) => text.is_empty(),
116            serde_json::Value::Array(items) => items.is_empty(),
117            serde_json::Value::Object(map) => map.is_empty(),
118            _ => false,
119        };
120        if !empty {
121            state.insert(key, value);
122        }
123    }
124    serde_json::Value::Object(state).to_string()
125}
126
127/// Mock distribution contract (E3.9). Mirrored in `src/jev_harness/client.py` and
128/// `packages/ts/src/client.ts`, and asserted against `tests/fixtures/mock_golden.json`.
129/// A signal *conflict* (explicit assertion next to an environment/transient signal) lowers the
130/// peak on purpose so a caller can exercise `escalate_to_system2` deterministically.
131pub const MOCK_CHOICE_BEST_PEAKED: f64 = 0.85;
132pub const MOCK_CHOICE_BEST_CONFLICT: f64 = 0.55;
133pub const MOCK_SCORE_BEST_PEAKED: f64 = 0.80;
134
135/// Abort action derived from the dead-end signals (parity with Python/TypeScript).
136pub fn mock_abort_action_choice(criteria: &HashMap<String, String>, state_lower: &str) -> String {
137    let mut step = state_lower.to_string();
138    for marker in ["proposed next step:", "proposed_next_step:"] {
139        if let Some((_, tail)) = step.split_once(marker) {
140            step = tail.to_string();
141        }
142    }
143    let forward = [
144        "implement",
145        "fix",
146        "resolve",
147        "correct",
148        "update",
149        "create",
150        "write",
151        "add",
152        "install",
153        "apply",
154        "corrigir",
155        "implementar",
156        "executar",
157        "validar",
158        "corregir",
159    ]
160    .iter()
161    .any(|w| step.contains(w));
162    let repetitive = [
163        "same",
164        "repetir",
165        "tentar novamente",
166        "intentar de nuevo",
167        "4a vez",
168        "again",
169        "identical",
170    ]
171    .iter()
172    .any(|w| step.contains(w));
173    let fatal = [
174        "impossible",
175        "impossivel",
176        "imposible",
177        "circular",
178        "deadlock",
179        "dead end",
180        "inviavel",
181        "inviable",
182        "hopeless",
183        "fatal",
184    ]
185    .iter()
186    .any(|w| state_lower.contains(w));
187    if repetitive || fatal {
188        return if criteria.contains_key("abort_and_ask") {
189            "abort_and_ask".to_string()
190        } else {
191            criteria.keys().next().cloned().unwrap_or_default()
192        };
193    }
194    if forward && criteria.contains_key("proceed") {
195        return "proceed".to_string();
196    }
197    String::new()
198}
199
200/// Peaked distribution over `options` summing to 1.0 (1.0 when there is a single option).
201pub fn mock_distribution(options: &[String], best: &str, peak: f64) -> HashMap<String, f64> {
202    let mut out = HashMap::new();
203    if options.len() <= 1 {
204        for option in options {
205            out.insert(option.clone(), 1.0);
206        }
207        return out;
208    }
209    let rest = (1.0 - peak) / (options.len() - 1) as f64;
210    for option in options {
211        out.insert(option.clone(), if option == best { peak } else { rest });
212    }
213    out
214}
215
216static ASSERTION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
217    regex::Regex::new(r"(?i)(?:assertionerror|assertionfailed|assertionfailederror|assert\b|assert_eq!|assertthat|expect\(.*?\)\.to|expected:.*received:|failures?:|fail(?:ed)?\s+test|falha de asserção|fallo de aserción|opentest4j)").expect("Invalid assertion regex")
218});
219
220// Success summaries emitted by common runners when a suite is green, plus the failure
221// signals that veto the `no_failure` short-circuit ("0 failed" is not a veto).
222static SUCCESS_PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
223    [
224        r"test result:\s*ok",
225        r"[1-9][\d,]*\s+passed\b",
226        r"[1-9]\d*\s+passing\b",
227        r"test suites?:\s*[1-9]\d*\s+passed",
228        r"[1-9]\d*\s+examples?,\s*0\s+failures",
229        r"all tests? passed",
230        r"\bbuild success(?:ful)?\b",
231        r"(?m)^\s*ok\s+\S+",
232    ]
233    .iter()
234    .map(|p| regex::Regex::new(p).expect("Invalid success pattern"))
235    .collect()
236});
237
238static FAILURE_COUNT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
239    regex::Regex::new(r"[1-9\x{FF11}-\x{FF19}\x{0661}-\x{0669}\x{06F1}-\x{06F9}][\d,._\x{00A0} \x{FF10}-\x{FF19}\x{0660}-\x{0669}\x{06F0}-\x{06F9}]*\s*(?:failures|failure|failed|failing|errors?)\b")
240        .expect("count")
241});
242static FAILURE_NOUN_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
243    regex::Regex::new(
244        r"\b[1-9\x{FF11}-\x{FF19}\x{0661}-\x{0669}\x{06F1}-\x{06F9}][\d,._\x{00A0} \x{FF10}-\x{FF19}\x{0660}-\x{0669}\x{06F0}-\x{06F9}]*\s+tests?\s+failed\b",
245    )
246    .expect("noun")
247});
248
249static FAILURE_ASSIGN_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
250    regex::Regex::new(r"(?:failures?|errors?|failed|failing)\s*[:=]\s*[1-9]").expect("assign")
251});
252static FAILURE_MARKER_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
253    // NOTE: `error:` must not be followed by \b — a colon before a space has no word boundary.
254    regex::Regex::new(
255        r"\b(?:traceback|panic|panicked|assertionerror|assertion failed|not ok)\b|error\s*:",
256    )
257    .expect("marker")
258});
259static UPPER_FAILED_REGEX: LazyLock<regex::Regex> =
260    LazyLock::new(|| regex::Regex::new(r"\bFAILED\b").expect("FAILED"));
261static UPPER_FAIL_LINE_REGEX: LazyLock<regex::Regex> =
262    LazyLock::new(|| regex::Regex::new(r"(?m)^\s*FAIL\b|(?m)---\s*FAIL\b").expect("FAIL"));
263static RAN_TESTS_REGEX: LazyLock<regex::Regex> =
264    LazyLock::new(|| regex::Regex::new(r"ran\s+[1-9]\d*\s+tests?").expect("ran tests"));
265static BARE_OK_LINE_REGEX: LazyLock<regex::Regex> =
266    LazyLock::new(|| regex::Regex::new(r"(?m)^\s*ok\s*$").expect("ok line"));
267
268/// A failure log is *untrusted input*: the model-jaggedness docs show that adversarial content
269/// in the state can steer a decision. These markers mean "this text is addressing the judge",
270/// so the log is escalated instead of classified. Kept identical to the Python and TS lists.
271static INJECTION_PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
272    [
273        r"(?i)ignore\s+(?:all\s+|any\s+|the\s+)?(?:previous|prior|above|earlier|foregoing)\s+(?:instruction|prompt|rule|direction|message)s?",
274        r"(?i)disregard\s+(?:all\s+|any\s+|the\s+)?(?:above|previous|prior|earlier|system)",
275        r"(?i)\b(?:ignore|bypass|override)\s+(?:the\s+)?(?:gate|harness|instructions?|safety|polic(?:y|ies))\b",
276        r"(?i)<\|(?:im_start|im_end|system|assistant|user)\|>",
277        r"\[/?(?:INST|SYS)\]",
278        r"(?im)###\s*(?:system|instruction|assistant)\b",
279        r#"(?i)"role"\s*:\s*"(?:system|assistant)"\s*,\s*"content""#,
280        r"(?i)\bskip_llm\s*[:=]\s*(?:true|false)\b",
281        r"(?i)\b(?:classif|labell?|mark|report|record|return|output|respond|answer)\w*\b[^.\n]{0,60}\b(?:as\s+)?(?:env_missing|flaky_transient|syntax_trivial|no_failure)\b",
282        r"(?i)\b(?:do\s+not|don't|never)\s+(?:call|invoke|use|escalate\s+to)\s+(?:the\s+)?(?:llm|model|api|system\s*2|frontier)\b",
283    ]
284    .iter()
285    .map(|p| regex::Regex::new(p).expect("injection pattern"))
286    .collect()
287});
288
289/// True when the log is trying to address the judge instead of describing a failure.
290pub fn looks_like_prompt_injection(log: &str) -> bool {
291    if log.is_empty() {
292        return false;
293    }
294    INJECTION_PATTERNS
295        .iter()
296        .any(|pattern| pattern.is_match(log))
297}
298
299/// Returns true only when a log is unequivocally a *successful* run summary.
300///
301/// Strict by design: a positive success summary is required AND every failure signal
302/// (non-zero counts, FAIL/FAILED markers, tracebacks, panics, dependency or transient
303/// errors) must be absent, so a real failure can never be short-circuited.
304pub fn looks_like_test_success(log: &str) -> bool {
305    if log.trim().is_empty() {
306        return false;
307    }
308    let text = log.to_lowercase();
309
310    if FAILURE_COUNT_REGEX.is_match(&text)
311        || FAILURE_NOUN_REGEX.is_match(&text)
312        || FAILURE_ASSIGN_REGEX.is_match(&text)
313        || FAILURE_MARKER_REGEX.is_match(&text)
314        || UPPER_FAILED_REGEX.is_match(log)
315        || UPPER_FAIL_LINE_REGEX.is_match(log)
316    {
317        return false;
318    }
319    if ["✗", "❌", "✘", "✕", "×", "‼"]
320        .iter()
321        .any(|m| log.contains(m))
322    {
323        return false;
324    }
325    let unclean = [
326        "module not found",
327        "no module named",
328        "cannot find module",
329        "cannot find crate",
330        "command not found",
331        "connection refused",
332        "connection reset",
333        "econnrefused",
334        "econnreset",
335        "etimedout",
336        "socket hang up",
337        "address already in use",
338        "timed out",
339        "timeout",
340    ];
341    if unclean.iter().any(|s| text.contains(s)) {
342        return false;
343    }
344
345    if SUCCESS_PATTERNS.iter().any(|p| p.is_match(&text)) {
346        return true;
347    }
348    RAN_TESTS_REGEX.is_match(&text) && BARE_OK_LINE_REGEX.is_match(&text)
349}
350
351static FAIL_LINE_REGEX: LazyLock<regex::Regex> =
352    LazyLock::new(|| regex::Regex::new(r"(?i)^fail(?:ed)?\b").expect("Invalid fail line regex"));
353
354static FAIL_TO_REGEX: LazyLock<regex::Regex> =
355    LazyLock::new(|| regex::Regex::new(r"(?i)^fail(?:ed)?\s+to\b").expect("Invalid fail-to regex"));
356
357static EXPECTED_RECEIVED_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
358    regex::Regex::new(r"(?is)expected:.{0,300}?received:").expect("Invalid expected/received regex")
359});
360
361static BARE_EXCEPTION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
362    regex::Regex::new(r"(?i)^(?:valueerror|runtimeerror|typeerror|keyerror|indexerror|zerodivisionerror|attributeerror|overflowerror|arithmeticerror|illegalargumentexception|illegalstateexception):").expect("Invalid bare exception regex")
363});
364
365static FAILURE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
366    regex::Regex::new(r"(?i)(?:assertionerror|assertionfailed|assertionfailederror|failures?:\s*[1-9]|failed\b|falhou\b|\d+\s+failed\b|not\s+ok\b|segmentation\s+fault|sigsegv|panic\b|core\s+dumped)").expect("Invalid failure regex")
367});
368
369static NEGATION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
370    regex::Regex::new(r"(?i)\b(not|do\s+not|don't|não|nao|no|never|sem|evitar|avoid)\s+(\w+\s+){0,3}(abort|abortar|stop|parar|detener|falhar|fail|deadlock|circular|dead\s*end)").expect("Invalid negation regex")
371});
372
373// Provider payload limits (jev-1.13: 64k tokens total; 32k for state + longest question).
374// Characters are a conservative proxy (~4 chars/token) with no external tokenizer.
375pub const MAX_STATE_CHARS: usize = 128_000;
376pub const MAX_TOTAL_CHARS: usize = 256_000;
377
378#[derive(Debug, Clone)]
379pub struct JevClient {
380    pub api_key: Option<String>,
381    pub base_url: String,
382    pub model: String,
383    /// Where the effective model came from: "argument" | "env" | ".jev.json" | "provider_default".
384    pub model_source: String,
385    pub provider: String,
386    pub timeout_ms: u64,
387    pub force_mock: bool,
388    /// Fail-open (default): degrade to the offline engine and mark the response.
389    pub fail_open: bool,
390    /// Maximum provider attempts for retryable failures (429/5xx/timeout/network).
391    pub max_retries: u32,
392    /// Base delay for exponential backoff in ms.
393    pub retry_base_delay_ms: u64,
394    http_client: reqwest::Client,
395}
396
397impl Default for JevClient {
398    fn default() -> Self {
399        Self::new(None, None, None, None, false)
400    }
401}
402
403impl JevClient {
404    pub fn new(
405        api_key: Option<String>,
406        base_url: Option<String>,
407        model: Option<String>,
408        timeout_ms: Option<u64>,
409        force_mock: bool,
410    ) -> Self {
411        let (resolved_key, resolved_provider, resolved_url) = Self::resolve_credentials(api_key);
412        let final_url = base_url.unwrap_or(resolved_url);
413        let (final_model, model_source) = Self::resolve_model(model, &resolved_provider);
414        let final_timeout = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
415
416        let http_client = reqwest::Client::builder()
417            .timeout(Duration::from_millis(final_timeout))
418            .user_agent(DEFAULT_USER_AGENT)
419            .build()
420            .unwrap_or_else(|_| reqwest::Client::new());
421
422        Self {
423            api_key: resolved_key,
424            base_url: final_url,
425            model: final_model,
426            model_source: model_source.to_string(),
427            provider: resolved_provider,
428            timeout_ms: final_timeout,
429            force_mock,
430            fail_open: true,
431            max_retries: 3,
432            retry_base_delay_ms: 500,
433            http_client,
434        }
435    }
436
437    /// Resolution order: explicit argument > `JEV_MODEL` env var > repository `.jev.json`
438    /// override > provider default. The generic placeholder (`jev-latest`, what
439    /// `jev-harness init` scaffolds) is treated as "no override" so scaffolded configs never
440    /// clobber provider model IDs.
441    pub fn resolve_model(model: Option<String>, provider: &str) -> (String, &'static str) {
442        if let Some(explicit) = model {
443            return (explicit, "argument");
444        }
445        if let Ok(from_env) = std::env::var("JEV_MODEL") {
446            let trimmed = from_env.trim();
447            if !trimmed.is_empty() {
448                return (trimmed.to_string(), "env");
449            }
450        }
451        if let Some(repo_model) = load_repo_config()
452            .model
453            .as_ref()
454            .filter(|m| m.as_str() != DEFAULT_MODEL)
455        {
456            return (repo_model.clone(), ".jev.json");
457        }
458        let provider_default = match provider {
459            "commandcode" => "typesafe/jev",
460            "opencode" => "jev-1.13-free",
461            "openrouter" => "typesafe/jev-1.13",
462            "vercel" => "typesafe-ai/jev",
463            _ => DEFAULT_MODEL,
464        };
465        (provider_default.to_string(), "provider_default")
466    }
467
468    pub fn with_mock() -> Self {
469        Self {
470            api_key: None,
471            base_url: TYPESAFE_API_URL.to_string(),
472            model: DEFAULT_MODEL.to_string(),
473            model_source: "provider_default".to_string(),
474            provider: "mock".to_string(),
475            timeout_ms: DEFAULT_TIMEOUT_MS,
476            force_mock: true,
477            fail_open: true,
478            max_retries: 3,
479            retry_base_delay_ms: 500,
480            http_client: reqwest::Client::new(),
481        }
482    }
483
484    pub fn with_provider(provider: &str, api_key: Option<String>) -> Self {
485        let prov = provider.trim().to_lowercase();
486        let base_url = match prov.as_str() {
487            "commandcode" => COMMANDCODE_API_URL.to_string(),
488            "opencode" => OPENCODE_API_URL.to_string(),
489            "openrouter" => OPENROUTER_API_URL.to_string(),
490            "vercel" => VERCEL_API_URL.to_string(),
491            _ => TYPESAFE_API_URL.to_string(),
492        };
493        let repo_config = load_repo_config();
494        let model = if let Some(repo_model) = repo_config
495            .model
496            .as_ref()
497            .filter(|m| m.as_str() != DEFAULT_MODEL)
498        {
499            repo_model.clone()
500        } else {
501            match prov.as_str() {
502                "commandcode" => "typesafe/jev".to_string(),
503                "opencode" => "jev-1.13-free".to_string(),
504                "openrouter" => "typesafe/jev-1.13".to_string(),
505                "vercel" => "typesafe-ai/jev".to_string(),
506                _ => DEFAULT_MODEL.to_string(),
507            }
508        };
509        let model_source = Self::resolve_model(None, &prov).1;
510        Self {
511            api_key,
512            base_url,
513            model,
514            model_source: model_source.to_string(),
515            provider: prov,
516            timeout_ms: DEFAULT_TIMEOUT_MS,
517            force_mock: false,
518            fail_open: true,
519            max_retries: 3,
520            retry_base_delay_ms: 500,
521            http_client: reqwest::Client::new(),
522        }
523    }
524
525    pub fn redact_secrets(text: &str, secret: Option<&str>) -> String {
526        if text.is_empty() {
527            return String::new();
528        }
529        let mut cleaned = text.to_string();
530        if let Some(sec) = secret {
531            if sec.len() >= 4 {
532                cleaned = cleaned.replace(sec, "[REDACTED]");
533            }
534        }
535        for prefix in ["Bearer ", "bearer ", "sk-", "vck_"] {
536            while let Some(idx) = cleaned.find(prefix) {
537                let end = cleaned[idx + prefix.len()..]
538                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_' && c != '-')
539                    .map(|offset| idx + prefix.len() + offset)
540                    .unwrap_or(cleaned.len());
541                if end > idx + prefix.len() {
542                    cleaned.replace_range(idx..end, "[REDACTED]");
543                } else {
544                    break;
545                }
546            }
547        }
548        cleaned.chars().take(400).collect()
549    }
550
551    fn resolve_credentials(explicit_key: Option<String>) -> (Option<String>, String, String) {
552        if let Some(key) = explicit_key {
553            if !key.trim().is_empty() {
554                return (
555                    Some(key),
556                    "typesafe".to_string(),
557                    TYPESAFE_API_URL.to_string(),
558                );
559            }
560        }
561
562        // 1. Check environment variables
563        if let Ok(p) = env::var("JEV_PROVIDER") {
564            let pt = p.trim();
565            if pt == "commandcode" {
566                let key = env::var("CMD_API_KEY")
567                    .or_else(|_| env::var("COMMAND_CODE_API_KEY"))
568                    .ok();
569                if let Some(k) = key {
570                    if !k.trim().is_empty() {
571                        return (
572                            Some(k),
573                            "commandcode".to_string(),
574                            COMMANDCODE_API_URL.to_string(),
575                        );
576                    }
577                }
578            } else if pt == "opencode" {
579                let key = env::var("OPENCODE_API_KEY").ok();
580                return (key, "opencode".to_string(), OPENCODE_API_URL.to_string());
581            }
582        }
583
584        if let Ok(key) = env::var("TYPESAFE_API_KEY") {
585            if !key.trim().is_empty() {
586                return (
587                    Some(key),
588                    "typesafe".to_string(),
589                    TYPESAFE_API_URL.to_string(),
590                );
591            }
592        }
593
594        for env_name in ["CMD_API_KEY", "COMMAND_CODE_API_KEY"] {
595            if let Ok(key) = env::var(env_name) {
596                if !key.trim().is_empty() {
597                    return (
598                        Some(key),
599                        "commandcode".to_string(),
600                        COMMANDCODE_API_URL.to_string(),
601                    );
602                }
603            }
604        }
605
606        if let Ok(key) = env::var("VERCEL_AI_GATEWAY_API_KEY") {
607            if !key.trim().is_empty() {
608                return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
609            }
610        }
611
612        if let Ok(key) = env::var("VERCEL_API_KEY") {
613            if !key.trim().is_empty() {
614                return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
615            }
616        }
617
618        if let Ok(key) = env::var("AI_GATEWAY_API_KEY") {
619            if !key.trim().is_empty() {
620                return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
621            }
622        }
623
624        if let Ok(key) = env::var("OPENCODE_API_KEY") {
625            if !key.trim().is_empty() {
626                return (
627                    Some(key),
628                    "opencode".to_string(),
629                    OPENCODE_API_URL.to_string(),
630                );
631            }
632        }
633
634        if let Ok(key) = env::var("OPENROUTER_API_KEY") {
635            if !key.trim().is_empty() {
636                return (
637                    Some(key),
638                    "openrouter".to_string(),
639                    OPENROUTER_API_URL.to_string(),
640                );
641            }
642        }
643
644        // 2. Check local repository .jev.json
645        if let Ok(content) = fs::read_to_string(".jev.json") {
646            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
647                let prov = val
648                    .get("provider")
649                    .and_then(|v| v.as_str())
650                    .unwrap_or("typesafe");
651                if prov == "opencode" {
652                    let k = val
653                        .get("api_key")
654                        .and_then(|v| v.as_str())
655                        .map(|s| s.to_string());
656                    return (k, "opencode".to_string(), OPENCODE_API_URL.to_string());
657                }
658                if let Some(k) = val.get("api_key").and_then(|v| v.as_str()) {
659                    if !k.trim().is_empty() {
660                        let url = match prov {
661                            "commandcode" => COMMANDCODE_API_URL,
662                            "openrouter" => OPENROUTER_API_URL,
663                            "vercel" => VERCEL_API_URL,
664                            _ => TYPESAFE_API_URL,
665                        };
666                        return (Some(k.to_string()), prov.to_string(), url.to_string());
667                    }
668                }
669            }
670        }
671
672        // 3. Check ~/.config/jev/credentials.env and ~/.commandcode/auth.json
673        if let Ok(home) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) {
674            let p = PathBuf::from(&home).join(".config/jev/credentials.env");
675            if let Ok(content) = fs::read_to_string(p) {
676                for line in content.lines() {
677                    let trimmed = line.trim();
678                    if trimmed.starts_with("JEV_PROVIDER=") && trimmed.contains("opencode") {
679                        return (None, "opencode".to_string(), OPENCODE_API_URL.to_string());
680                    }
681                    if trimmed.starts_with("CMD_API_KEY=")
682                        || trimmed.starts_with("COMMAND_CODE_API_KEY=")
683                    {
684                        let k = trimmed
685                            .split('=')
686                            .nth(1)
687                            .unwrap_or("")
688                            .trim_matches('"')
689                            .trim();
690                        if !k.is_empty() {
691                            return (
692                                Some(k.to_string()),
693                                "commandcode".to_string(),
694                                COMMANDCODE_API_URL.to_string(),
695                            );
696                        }
697                    }
698                    if trimmed.starts_with("OPENCODE_API_KEY=") {
699                        let k = trimmed
700                            .trim_start_matches("OPENCODE_API_KEY=")
701                            .trim_matches('"');
702                        if !k.is_empty() {
703                            return (
704                                Some(k.to_string()),
705                                "opencode".to_string(),
706                                OPENCODE_API_URL.to_string(),
707                            );
708                        }
709                    }
710                    if trimmed.starts_with("TYPESAFE_API_KEY=") {
711                        let k = trimmed
712                            .trim_start_matches("TYPESAFE_API_KEY=")
713                            .trim_matches('"');
714                        if !k.is_empty() {
715                            return (
716                                Some(k.to_string()),
717                                "typesafe".to_string(),
718                                TYPESAFE_API_URL.to_string(),
719                            );
720                        }
721                    }
722                    if trimmed.starts_with("VERCEL_AI_GATEWAY_API_KEY=")
723                        || trimmed.starts_with("VERCEL_API_KEY=")
724                        || trimmed.starts_with("AI_GATEWAY_API_KEY=")
725                    {
726                        let k = trimmed
727                            .split('=')
728                            .nth(1)
729                            .unwrap_or("")
730                            .trim_matches('"')
731                            .trim();
732                        if !k.is_empty() {
733                            return (
734                                Some(k.to_string()),
735                                "vercel".to_string(),
736                                VERCEL_API_URL.to_string(),
737                            );
738                        }
739                    }
740                }
741            }
742
743            let cmd_auth = PathBuf::from(&home).join(".commandcode/auth.json");
744            if let Ok(content) = fs::read_to_string(cmd_auth) {
745                if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
746                    let key_opt = val
747                        .get("apiKey")
748                        .or_else(|| val.get("api_key"))
749                        .and_then(|v| v.as_str());
750                    if let Some(k) = key_opt {
751                        let kt = k.trim();
752                        if !kt.is_empty() {
753                            return (
754                                Some(kt.to_string()),
755                                "commandcode".to_string(),
756                                COMMANDCODE_API_URL.to_string(),
757                            );
758                        }
759                    }
760                }
761            }
762        }
763
764        (None, "mock".to_string(), TYPESAFE_API_URL.to_string())
765    }
766
767    /// Builder for the failure policy used by every command:
768    /// fail-open is the default for gates; fail-closed surfaces errors instead.
769    pub fn with_failure_policy(
770        mut self,
771        fail_open: bool,
772        max_retries: u32,
773        retry_base_delay_ms: u64,
774    ) -> Self {
775        self.fail_open = fail_open;
776        self.max_retries = max_retries.max(1);
777        self.retry_base_delay_ms = retry_base_delay_ms;
778        self
779    }
780
781    /// Exponential backoff honoring a provider `Retry-After`. Fractional seconds are accepted so
782    /// the three runtimes agree on the delay; the value is capped for fast CI.
783    pub fn retry_delay_ms(&self, attempt: u32, retry_after: Option<f64>) -> u64 {
784        if let Some(seconds) = retry_after {
785            if seconds.is_finite() && seconds >= 0.0 {
786                return ((seconds * 1000.0) as u64).min(30_000);
787            }
788        }
789        let factor = 1u64 << attempt.saturating_sub(1).min(6);
790        self.retry_base_delay_ms.saturating_mul(factor).min(5_000)
791    }
792
793    pub async fn system_one(
794        &self,
795        state: &str,
796        questions: HashMap<String, Question>,
797    ) -> Result<JevResponse, JevError> {
798        // E3.1: a question with a single option has no distribution to measure.
799        for question in questions.values() {
800            match question {
801                Question::Choice(choice) => {
802                    crate::uncertainty::validate_question_options(choice.criteria.len())
803                        .map_err(JevError::Config)?
804                }
805                Question::Score(score) => {
806                    crate::uncertainty::validate_question_options(score.criteria.len())
807                        .map_err(JevError::Config)?
808                }
809                Question::Noul(_) => {}
810            }
811        }
812        let redacted_state = redact_secrets(state);
813        let state = redacted_state.as_str();
814        let is_live = !self.force_mock && (self.provider == "opencode" || self.api_key.is_some());
815        if !is_live {
816            return Ok(self.simulate_system_one(state, &questions, &self.model));
817        }
818
819        // Count code points (not UTF-8 bytes) so the three runtimes agree on the limit.
820        let state_chars = state.chars().count();
821        let questions_chars = serde_json::to_string(&questions)
822            .map(|s| s.chars().count())
823            .unwrap_or(0);
824        if state_chars > MAX_STATE_CHARS || state_chars + questions_chars > MAX_TOTAL_CHARS {
825            return Err(JevError::Config(format!(
826                "Payload exceeds the provider limit: {} state chars + {} question chars (limit: {} state / {} total, ~32k/64k tokens). Trim the state or split the questions.",
827                state_chars,
828                questions_chars,
829                MAX_STATE_CHARS,
830                MAX_TOTAL_CHARS
831            )));
832        }
833
834        let mut payload = serde_json::json!({
835            "model": self.model,
836            "state": state,
837            "questions": questions
838        });
839        if self.provider == "openrouter" {
840            payload["provider"] =
841                serde_json::json!({ "only": ["typesafe"], "allow_fallbacks": false });
842        } else if self.provider == "vercel" {
843            payload["providerOptions"] =
844                serde_json::json!({ "gateway": { "only": ["typesafe-ai"] } });
845        }
846
847        let mut attempt: u32 = 0;
848        loop {
849            attempt += 1;
850            let attempt_started = std::time::Instant::now();
851            let mut req = self
852                .http_client
853                .post(&self.base_url)
854                .header("Content-Type", "application/json");
855
856            if let Some(ref key) = self.api_key {
857                if key != "zen" {
858                    req = req.header("Authorization", format!("Bearer {}", key));
859                }
860            }
861            if self.provider == "openrouter" {
862                req = req
863                    .header(
864                        "HTTP-Referer",
865                        "https://github.com/ismaelsoilet/jev-harness",
866                    )
867                    .header("X-Title", "Jev Harness");
868            }
869
870            let resp_result = req.json(&payload).send().await;
871
872            match resp_result {
873                Ok(resp) => {
874                    let status = resp.status().as_u16();
875                    if !resp.status().is_success() {
876                        let retry_after = resp
877                            .headers()
878                            .get("retry-after")
879                            .and_then(|v| v.to_str().ok())
880                            .and_then(|s| s.trim().parse::<f64>().ok());
881                        let raw_text = resp.text().await.unwrap_or_default();
882                        let text = Self::redact_secrets(&raw_text, self.api_key.as_deref());
883                        if status == 401 || status == 403 {
884                            if !self.fail_open {
885                                return Err(JevError::Api {
886                                    status,
887                                    message: text,
888                                });
889                            }
890                            eprintln!("[JEV WARNING] {} auth failed (HTTP {}); falling back to offline simulation.", self.provider, status);
891                            let mut sim = self.simulate_system_one(state, &questions, &self.model);
892                            sim.degraded_reason = format!("auth_{}", status);
893                            return Ok(sim);
894                        }
895                        let retryable = status == 429 || status >= 500;
896                        if retryable && attempt < self.max_retries {
897                            let delay = self.retry_delay_ms(attempt, retry_after);
898                            if delay > 0 {
899                                tokio::time::sleep(Duration::from_millis(delay)).await;
900                            }
901                            continue;
902                        }
903                        if self.fail_open {
904                            eprintln!(
905                                "[JEV WARNING] {} API HTTP {}; falling back to offline simulation.",
906                                self.provider, status
907                            );
908                            let mut sim = self.simulate_system_one(state, &questions, &self.model);
909                            sim.degraded_reason = format!("http_{}", status);
910                            return Ok(sim);
911                        }
912                        return Err(JevError::Api {
913                            status,
914                            message: text,
915                        });
916                    }
917
918                    match resp.json::<serde_json::Value>().await {
919                        Ok(parsed) => match self.parse_api_response(&parsed) {
920                            Ok(response) => return Ok(response),
921                            Err(e) => {
922                                if attempt < self.max_retries {
923                                    let delay = self.retry_delay_ms(attempt, None);
924                                    if delay > 0 {
925                                        tokio::time::sleep(Duration::from_millis(delay)).await;
926                                    }
927                                    continue;
928                                }
929                                if self.fail_open {
930                                    eprintln!(
931                                        "[JEV WARNING] {} returned a malformed response: {}; falling back to offline simulation.",
932                                        self.provider, e
933                                    );
934                                    let mut sim =
935                                        self.simulate_system_one(state, &questions, &self.model);
936                                    sim.degraded_reason = "invalid_response".to_string();
937                                    return Ok(sim);
938                                }
939                                return Err(e);
940                            }
941                        },
942                        Err(_) => {
943                            if attempt < self.max_retries {
944                                let delay = self.retry_delay_ms(attempt, None);
945                                if delay > 0 {
946                                    tokio::time::sleep(Duration::from_millis(delay)).await;
947                                }
948                                continue;
949                            }
950                            if self.fail_open {
951                                eprintln!("[JEV WARNING] {} returned a non-JSON response; falling back to offline simulation.", self.provider);
952                                let mut sim =
953                                    self.simulate_system_one(state, &questions, &self.model);
954                                sim.degraded_reason = "invalid_response".to_string();
955                                return Ok(sim);
956                            }
957                            return Err(JevError::Config(
958                                "Provider returned a non-JSON response".to_string(),
959                            ));
960                        }
961                    }
962                }
963                Err(e) => {
964                    let retryable = e.is_timeout() || e.is_connect() || e.is_request();
965                    if retryable && attempt < self.max_retries {
966                        let delay = self.retry_delay_ms(attempt, None);
967                        if delay > 0 {
968                            tokio::time::sleep(Duration::from_millis(delay)).await;
969                        }
970                        continue;
971                    }
972                    if self.fail_open {
973                        // A read timeout surfaces as a generic request error: classify by
974                        // elapsed time so the marker matches Python and TypeScript.
975                        let elapsed_ratio = attempt_started.elapsed().as_millis() as u64;
976                        let timed_out = e.is_timeout()
977                            || (!e.is_connect()
978                                && elapsed_ratio >= self.timeout_ms.saturating_mul(9) / 10);
979                        eprintln!("[JEV WARNING] {} transport error ({}); falling back to offline simulation.", self.provider, e);
980                        let mut sim = self.simulate_system_one(state, &questions, &self.model);
981                        sim.degraded_reason = if timed_out {
982                            "timeout".to_string()
983                        } else {
984                            "connection".to_string()
985                        };
986                        return Ok(sim);
987                    }
988                    return Err(JevError::Http(e));
989                }
990            }
991        }
992    }
993
994    /// Strictly parses a provider payload. A 200 whose fields have the wrong types is an error
995    /// (handled by the failure policy), never a silently dropped answer or a defaulted score.
996    pub fn parse_api_response(&self, val: &serde_json::Value) -> Result<JevResponse, JevError> {
997        let model = val
998            .get("model")
999            .and_then(|v| v.as_str())
1000            .unwrap_or(&self.model)
1001            .to_string();
1002
1003        let mut answers = HashMap::new();
1004
1005        let raw_answers = val.get("answers").filter(|v| !v.is_null());
1006        if let Some(raw) = raw_answers {
1007            let ans_obj = raw.as_object().ok_or_else(|| {
1008                JevError::Config("malformed response: 'answers' must be a JSON object".to_string())
1009            })?;
1010            for (k, v) in ans_obj {
1011                let parsed = serde_json::from_value::<Answer>(v.clone()).map_err(|e| {
1012                    JevError::Config(format!(
1013                        "malformed response: answer '{}' could not be parsed: {}",
1014                        k, e
1015                    ))
1016                })?;
1017                answers.insert(k.clone(), parsed);
1018            }
1019        }
1020
1021        if answers.is_empty() {
1022            // A live response with nothing usable would silently make every gate fall back to
1023            // its defaults; that must be a visible degradation instead.
1024            return Err(JevError::Config(
1025                "malformed response: no answers could be parsed".to_string(),
1026            ));
1027        }
1028
1029        let usage = val
1030            .get("usage")
1031            .and_then(|v| serde_json::from_value::<JevUsage>(v.clone()).ok())
1032            .unwrap_or_default();
1033
1034        Ok(JevResponse {
1035            model,
1036            answers,
1037            usage,
1038            is_mock: false,
1039            degraded_reason: String::new(),
1040        })
1041    }
1042
1043    /// Fast regex-based deterministic decision simulation (< 500µs).
1044    pub fn simulate_system_one(
1045        &self,
1046        state: &str,
1047        questions: &HashMap<String, Question>,
1048        model_name: &str,
1049    ) -> JevResponse {
1050        let rendered_state = render_state_text(state);
1051        let state = rendered_state.as_str();
1052        let state_lower = state.to_lowercase();
1053        let state_tokens: HashSet<String> = state_lower
1054            .split(|c: char| !c.is_alphanumeric() && c != '_')
1055            .filter(|s| !s.is_empty())
1056            .map(|s| s.to_string())
1057            .collect();
1058
1059        let real_assertion = state_lower.lines().any(|line| {
1060            let t = line.trim();
1061            t.starts_with("panicked at")
1062                || t.starts_with("panic:")
1063                || ASSERTION_REGEX.is_match(t)
1064                // Bare pytest/Jest `FAIL`/`FAILED` lines are assertions, but prose such as
1065                // "Failed to start the server" is a transient/environment report. Two regexes
1066                // mirror Python/TS `^fail(?:ed)?(?!\s+to\b)\b` exactly (the regex crate has no
1067                // lookahead), so `failing tests:` / `failsafe mode` are not treated as assertions.
1068                || (FAIL_LINE_REGEX.is_match(t) && !FAIL_TO_REGEX.is_match(t))
1069        }) || EXPECTED_RECEIVED_REGEX.is_match(&state_lower);
1070        let bare_exception = state_lower
1071            .lines()
1072            .any(|line| BARE_EXCEPTION_REGEX.is_match(line.trim()));
1073        let has_explicit_failure = FAILURE_REGEX.is_match(&state_lower);
1074
1075        let heavy_kw = [
1076            "kernel",
1077            "distributed",
1078            "architecture",
1079            "refactor",
1080            "concurrency",
1081            "deadlock",
1082            "multi-file",
1083            "consensus",
1084            "supervision tree",
1085            "arquitetura",
1086            "distribuído",
1087            "distribuída",
1088            "distribuido",
1089            "refatorar",
1090            "refatoração",
1091            "concorrência",
1092            "concorrencia",
1093            "consenso",
1094            "múltiplos arquivos",
1095            "condição de corrida",
1096            "arquitectura",
1097            "concurrencia",
1098            "condición de carrera",
1099            "múltiples archivos",
1100        ];
1101        let has_heavy_keywords = heavy_kw.iter().any(|k| state_lower.contains(k));
1102
1103        let env_missing_triggers = [
1104            "modulenotfounderror",
1105            "no module named",
1106            "importerror",
1107            "cannot find module",
1108            "err_module_not_found",
1109            "ts2307",
1110            "cannot find crate",
1111            "can't find crate",
1112            "e0463",
1113            "cannot find package",
1114            "no required module provides package",
1115            "classnotfoundexception",
1116            "noclassdeffounderror",
1117            "package does not exist",
1118            "no such file or directory",
1119            "command not found",
1120            "module not found",
1121            "package not found",
1122            "crate not found",
1123            "cs0246",
1124            "type or namespace name",
1125            "cannot load such file",
1126            "loaderror",
1127            "módulo não encontrado",
1128            "modulo nao encontrado",
1129            "nenhum módulo chamado",
1130            "pacote não encontrado",
1131            "módulo no encontrado",
1132            "modulo no encontrado",
1133            "no se encontró el módulo",
1134            "paquete no encontrado",
1135        ];
1136        let flaky_triggers = [
1137            "connectionreset",
1138            "timeout",
1139            "timed out",
1140            "econnreset",
1141            "econnrefused",
1142            "etimedout",
1143            "socket hang up",
1144            "gateway timeout",
1145            "503 service unavailable",
1146            "tempo limite",
1147            "tempo limite esgotado",
1148            "conexão recusada",
1149            "conexao recusada",
1150            "tiempo de espera agotado",
1151            "conexión rechazada",
1152            "conexion rechazada",
1153            "already in use",
1154            "address already in use",
1155            "eaddrinuse",
1156            "port already in use",
1157            "port is already in use",
1158            "porta já está em uso",
1159            "puerto ya está en uso",
1160        ];
1161
1162        // Precedence rule (.agents/rules/04): an explicit assertion/expectation mismatch always
1163        // outranks dependency or transient words in the same log. A bare exception name is logic
1164        // evidence only when no concrete env/flaky root cause is present.
1165        let has_env_signal = env_missing_triggers.iter().any(|k| state_lower.contains(k));
1166        let has_flaky_signal = flaky_triggers.iter().any(|k| state_lower.contains(k));
1167        let is_explicit_assertion =
1168            real_assertion || (bare_exception && !(has_env_signal || has_flaky_signal));
1169        let syntax_triggers = [
1170            "syntaxerror",
1171            "indentationerror",
1172            "expected ';'",
1173            "ts1005",
1174            "missing bracket",
1175            "erro de sintaxe",
1176            "sintaxe inválida",
1177            "indentação inesperada",
1178            "error de sintaxis",
1179            "sintaxis inválida",
1180        ];
1181        let deep_logic_triggers = [
1182            "assertionerror",
1183            "assertionfailed",
1184            "assertionfailederror",
1185            "assert ",
1186            "panicked at",
1187            "panic:",
1188            "panic",
1189            "deadlock",
1190            "goroutines are asleep",
1191            "infinite loop",
1192            "loop infinito",
1193            "bucle infinito",
1194            "bloqueo mutuo",
1195            "mutex",
1196            "segmentation fault",
1197            "sigsegv",
1198            "addresssanitizer",
1199            "core dumped",
1200            "nullpointerexception",
1201            "nullreferenceexception",
1202            "arrayindexoutofboundsexception",
1203            "nil pointer dereference",
1204            "index out of bounds",
1205            "falha de asserção",
1206            "asserção",
1207            "erro de lógica",
1208            "fallo de aserción",
1209            "error de lógica",
1210            "expect(",
1211        ];
1212        let single_word_mech = [
1213            "git", "diff", "typo", "flake8", "eslint", "prettier", "linter", "echo", "pwd",
1214            "format", "black", "lint", "cat", "ls",
1215        ];
1216        let multi_word_mech = [
1217            "git status",
1218            "git diff",
1219            "git log",
1220            "view file",
1221            "read file",
1222            "cat file",
1223            "check status",
1224            "run linter",
1225            "fix typo",
1226            "ler arquivo",
1227            "verificar arquivo",
1228            "formatar código",
1229            "leer archivo",
1230            "corregir errata",
1231            "listar arquivos",
1232            "listar diretório",
1233        ];
1234        let has_mech_trigger = single_word_mech.iter().any(|w| state_tokens.contains(*w))
1235            || multi_word_mech.iter().any(|p| state_lower.contains(p));
1236
1237        let is_negated_abort = NEGATION_REGEX.is_match(state);
1238
1239        let mut answers = HashMap::new();
1240
1241        for (qid, q) in questions {
1242            match q {
1243                Question::Choice(cq) => {
1244                    let mut best_choice = if cq.criteria.contains_key("deep_logic") {
1245                        "deep_logic".to_string()
1246                    } else if cq.criteria.contains_key("lightweight_system2") {
1247                        "lightweight_system2".to_string()
1248                    } else if cq.criteria.contains_key("proceed") {
1249                        "proceed".to_string()
1250                    } else {
1251                        cq.criteria.keys().next().cloned().unwrap_or_default()
1252                    };
1253                    // The abort gate's action is derived from the same signals as its dead-end
1254                    // question: letting token overlap pick "abort_and_ask" beside a low dead-end
1255                    // probability made the gate contradict its own evidence (parity with Python/TS).
1256                    let derived_abort_action = if cq.criteria.contains_key("abort_and_ask")
1257                        && cq.criteria.contains_key("proceed")
1258                    {
1259                        mock_abort_action_choice(&cq.criteria, &state_lower)
1260                    } else {
1261                        String::new()
1262                    };
1263                    if !derived_abort_action.is_empty() {
1264                        best_choice = derived_abort_action.clone();
1265                    }
1266                    let mut best_score: i32 = 0;
1267
1268                    // Canonical (sorted) order so ties break identically in every runtime.
1269                    let mut ordered_keys: Vec<&String> = cq.criteria.keys().collect();
1270                    ordered_keys.sort();
1271                    for opt in ordered_keys {
1272                        let desc = &cq.criteria[opt];
1273                        let opt_text = format!("{} {}", opt, desc).to_lowercase();
1274                        let opt_tokens: Vec<&str> = opt_text
1275                            .split(|c: char| !c.is_alphanumeric() && c != '_')
1276                            .filter(|s| !s.is_empty())
1277                            .collect();
1278
1279                        let mut score = opt_tokens
1280                            .iter()
1281                            .filter(|t| state_tokens.contains(**t))
1282                            .count() as i32;
1283
1284                        if state_lower.contains(&opt.to_lowercase()) {
1285                            score += 3;
1286                        }
1287
1288                        let has_deadlock_or_loop = [
1289                            "infinite loop",
1290                            "loop infinito",
1291                            "bucle infinito",
1292                            "deadlock",
1293                            "deadlock!",
1294                            "bloqueo mutuo",
1295                            "goroutines are asleep",
1296                            "mutex",
1297                            "thread hung",
1298                        ]
1299                        .iter()
1300                        .any(|k| state_lower.contains(k));
1301
1302                        // Domain heuristics
1303                        if opt == "deep_logic" {
1304                            if deep_logic_triggers.iter().any(|t| state_lower.contains(t)) {
1305                                score += 8;
1306                            }
1307                            if is_explicit_assertion || has_deadlock_or_loop {
1308                                score += 18;
1309                            }
1310                        } else if opt == "env_missing" {
1311                            if env_missing_triggers.iter().any(|t| state_lower.contains(t)) {
1312                                score += if is_explicit_assertion { 0 } else { 7 };
1313                            }
1314                        } else if opt == "flaky_transient" && !has_deadlock_or_loop {
1315                            if flaky_triggers.iter().any(|t| state_lower.contains(t)) {
1316                                score += if is_explicit_assertion { 0 } else { 7 };
1317                            }
1318                        } else if opt == "syntax_trivial" {
1319                            if syntax_triggers.iter().any(|t| state_lower.contains(t)) {
1320                                score += 6;
1321                            }
1322                        } else if opt == "deterministic" {
1323                            if has_mech_trigger
1324                                || ["bash", "regex", "script"]
1325                                    .iter()
1326                                    .any(|k| state_tokens.contains(*k))
1327                            {
1328                                score += if has_heavy_keywords { 2 } else { 7 };
1329                            }
1330                        } else if opt == "heavy_system2" {
1331                            if has_heavy_keywords {
1332                                score += 15;
1333                            }
1334                        } else if opt == "abort_and_ask" {
1335                            if !is_negated_abort {
1336                                let triggers = [
1337                                    "repeat",
1338                                    "circular",
1339                                    "deadlock",
1340                                    "same",
1341                                    "tentar novamente",
1342                                    "intentar de nuevo",
1343                                    "mesma",
1344                                    "abort",
1345                                ];
1346                                if triggers.iter().any(|t| state_lower.contains(t)) {
1347                                    score += 8;
1348                                }
1349                            }
1350                        } else if opt == "proceed" {
1351                            let triggers = [
1352                                "proceed",
1353                                "unit test",
1354                                "test",
1355                                "verify",
1356                                "verifying",
1357                                "incremental",
1358                                "progress",
1359                                "implement",
1360                                "add",
1361                                "adicionar",
1362                                "migration",
1363                            ];
1364                            if is_negated_abort || triggers.iter().any(|t| state_lower.contains(t))
1365                            {
1366                                score += 8;
1367                            }
1368                        }
1369
1370                        if !derived_abort_action.is_empty() {
1371                            continue; // derived from the dead-end signal, not overlap
1372                        }
1373                        if score > best_score {
1374                            best_score = score;
1375                            best_choice = opt.clone();
1376                        }
1377                    }
1378
1379                    let is_effort_q = qid == "effort"
1380                        || [
1381                            "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
1382                        ]
1383                        .iter()
1384                        .any(|eff| cq.criteria.contains_key(*eff));
1385
1386                    if is_effort_q {
1387                        if has_heavy_keywords
1388                            || [
1389                                "deadlock",
1390                                "race condition",
1391                                "distributed",
1392                                "concurrency",
1393                                "kernel",
1394                                "supervision",
1395                                "architectural",
1396                                "complex",
1397                                "algorithmic",
1398                                "deadlocks",
1399                                "concorrência",
1400                                "arquitetura",
1401                                "condição de corrida",
1402                                "arquitectura",
1403                                "concurrencia",
1404                            ]
1405                            .iter()
1406                            .any(|k| state_lower.contains(k))
1407                        {
1408                            best_choice = if cq.criteria.contains_key("ultra")
1409                                && state_lower.contains("beyond max")
1410                            {
1411                                "ultra".to_string()
1412                            } else if cq.criteria.contains_key("max")
1413                                && (state_lower.contains("first principles")
1414                                    || state_lower.contains("proof"))
1415                            {
1416                                "max".to_string()
1417                            } else if cq.criteria.contains_key("xhigh")
1418                                && (state_lower.contains("first principles")
1419                                    || state_lower.contains("subsystems"))
1420                            {
1421                                "xhigh".to_string()
1422                            } else if cq.criteria.contains_key("high") {
1423                                "high".to_string()
1424                            } else if cq.criteria.contains_key("xhigh") {
1425                                "xhigh".to_string()
1426                            } else if cq.criteria.contains_key("max") {
1427                                "max".to_string()
1428                            } else if cq.criteria.contains_key("medium") {
1429                                "medium".to_string()
1430                            } else {
1431                                cq.criteria.keys().last().cloned().unwrap_or_default()
1432                            };
1433                        } else if has_mech_trigger && !has_heavy_keywords {
1434                            best_choice = if cq.criteria.contains_key("none")
1435                                && ["git status", "pwd", "echo", "version"]
1436                                    .iter()
1437                                    .any(|m| state_lower.contains(m))
1438                            {
1439                                "none".to_string()
1440                            } else if cq.criteria.contains_key("minimal")
1441                                && ["git status", "pwd", "echo", "version"]
1442                                    .iter()
1443                                    .any(|m| state_lower.contains(m))
1444                            {
1445                                "minimal".to_string()
1446                            } else if cq.criteria.contains_key("low") {
1447                                "low".to_string()
1448                            } else if cq.criteria.contains_key("minimal") {
1449                                "minimal".to_string()
1450                            } else if cq.criteria.contains_key("none") {
1451                                "none".to_string()
1452                            } else if cq.criteria.contains_key("medium") {
1453                                "medium".to_string()
1454                            } else {
1455                                cq.criteria.keys().next().cloned().unwrap_or_default()
1456                            };
1457                        } else {
1458                            best_choice = if cq.criteria.contains_key("medium") {
1459                                "medium".to_string()
1460                            } else if cq.criteria.contains_key("high") {
1461                                "high".to_string()
1462                            } else if cq.criteria.contains_key("low") {
1463                                "low".to_string()
1464                            } else {
1465                                cq.criteria.keys().next().cloned().unwrap_or_default()
1466                            };
1467                        }
1468                    } else if qid == "lease"
1469                        || (cq.criteria.contains_key("1")
1470                            && ["2", "5", "10"]
1471                                .iter()
1472                                .any(|x| cq.criteria.contains_key(*x)))
1473                    {
1474                        // Astra-Ares multi-generation lease question
1475                        if [
1476                            "error",
1477                            "fail",
1478                            "erro",
1479                            "falha",
1480                            "deadlock",
1481                            "panic",
1482                            "exception",
1483                        ]
1484                        .iter()
1485                        .any(|k| state_lower.contains(k))
1486                        {
1487                            best_choice = "1".to_string();
1488                        } else if has_mech_trigger && !has_heavy_keywords {
1489                            best_choice = if cq.criteria.contains_key("5") {
1490                                "5".to_string()
1491                            } else if cq.criteria.contains_key("2") {
1492                                "2".to_string()
1493                            } else {
1494                                "1".to_string()
1495                            };
1496                        } else {
1497                            best_choice = if cq.criteria.contains_key("2") {
1498                                "2".to_string()
1499                            } else if cq.criteria.contains_key("5") {
1500                                "5".to_string()
1501                            } else {
1502                                "1".to_string()
1503                            };
1504                        }
1505                        if !cq.criteria.contains_key(&best_choice) {
1506                            best_choice = cq.criteria.keys().next().cloned().unwrap_or_default();
1507                        }
1508                    } else if qid == "workflow_phase"
1509                        || (cq.criteria.contains_key("execute")
1510                            && cq.criteria.contains_key("complete"))
1511                    {
1512                        // Canonical phase contract (parity with Python/TypeScript).
1513                        let is_waiting_q = state_lower.contains('?')
1514                            || [
1515                                "waiting for your",
1516                                "waiting on user",
1517                                "wait for my go-ahead",
1518                                "please confirm",
1519                                "which option",
1520                                "do you approve",
1521                                "would you like me to",
1522                                "do you want me to",
1523                                "need your api key",
1524                                "aguardando sua aprovação",
1525                                "aguardando usuário",
1526                                "qual opção você prefere",
1527                                "qual opção",
1528                                "preciso que você confirme",
1529                                "preciso de permissão",
1530                                "need clarification",
1531                                "please clarify",
1532                                "need permission",
1533                            ]
1534                            .iter()
1535                            .any(|w| state_lower.contains(w));
1536                        let is_unverified = [
1537                            "without running tests",
1538                            "tests not run",
1539                            "unverified",
1540                            "haven't run pytest",
1541                            "todo: run tests",
1542                            "falta rodar os testes",
1543                            "sem testar",
1544                            "need to verify",
1545                            "to verify",
1546                            "run pytest",
1547                            "run cargo test",
1548                            "run npm test",
1549                            "need to run",
1550                            "updated file",
1551                            "edited file",
1552                            "finished editing",
1553                            "modified file",
1554                            "wrote code",
1555                            "atualizei o arquivo",
1556                            "alterei o arquivo",
1557                            "terminei de editar",
1558                            "arquivo alterado",
1559                        ]
1560                        .iter()
1561                        .any(|w| state_lower.contains(w));
1562                        let is_unfinished = [
1563                            "next i'll",
1564                            "next i will",
1565                            "now i will",
1566                            "continuarei",
1567                            "a seguir vou",
1568                            "próximo passo farei",
1569                            "1 of 5",
1570                            "2 of 5",
1571                            "3 of 5",
1572                            "4 of 5",
1573                            "step 1 of",
1574                            "step 1 done",
1575                            "unfinished",
1576                            "remaining",
1577                            "todo:",
1578                            "pendente",
1579                            "partial",
1580                            "parcial",
1581                            "in progress",
1582                            "falta implementar",
1583                            "falta rodar os testes",
1584                            "sem testar",
1585                            "without running tests",
1586                            "tests not run",
1587                            "haven't run pytest",
1588                            "need to run",
1589                            "need to verify",
1590                            "to verify",
1591                            "unverified",
1592                            "run pytest",
1593                            "run cargo test",
1594                            "run npm test",
1595                            "updated file",
1596                            "edited file",
1597                            "finished editing",
1598                            "modified file",
1599                            "wrote code",
1600                            "atualizei o arquivo",
1601                            "alterei o arquivo",
1602                            "terminei de editar",
1603                            "arquivo alterado",
1604                            "next step",
1605                        ]
1606                        .iter()
1607                        .any(|w| state_lower.contains(w));
1608                        let is_complete = [
1609                            "all done",
1610                            "100% passing",
1611                            "all criteria satisfied",
1612                            "tudo concluído",
1613                            "todas as etapas concluídas",
1614                            "task complete",
1615                            "konnichiwa! all done",
1616                            "all tests passed",
1617                            "tests passed (0 failed)",
1618                            "completed and verified",
1619                            "completed all",
1620                            "concluído com sucesso",
1621                            "todos os testes passaram",
1622                        ]
1623                        .iter()
1624                        .any(|w| state_lower.contains(w));
1625
1626                        if is_waiting_q && cq.criteria.contains_key("ask") {
1627                            best_choice = "ask".to_string();
1628                        } else if is_complete && cq.criteria.contains_key("complete") {
1629                            best_choice = "complete".to_string();
1630                        } else if is_unverified && cq.criteria.contains_key("verify") {
1631                            best_choice = "verify".to_string();
1632                        } else if is_unfinished && cq.criteria.contains_key("execute") {
1633                            best_choice = "execute".to_string();
1634                        }
1635                        // No explicit fallback: an undecided phase keeps the generic scoring
1636                        // result, matching Python and TypeScript.
1637                    }
1638
1639                    let has_deadlock_or_loop = [
1640                        "infinite loop",
1641                        "loop infinito",
1642                        "bucle infinito",
1643                        "deadlock",
1644                        "deadlock!",
1645                        "bloqueo mutuo",
1646                        "goroutines are asleep",
1647                        "mutex",
1648                    ]
1649                    .iter()
1650                    .any(|k| state_lower.contains(k));
1651                    let has_signal_conflict = (is_explicit_assertion || has_deadlock_or_loop)
1652                        && (has_env_signal || has_flaky_signal);
1653                    let options: Vec<String> = cq.criteria.keys().cloned().collect();
1654                    let probs = mock_distribution(
1655                        &options,
1656                        &best_choice,
1657                        if has_signal_conflict {
1658                            MOCK_CHOICE_BEST_CONFLICT
1659                        } else {
1660                            MOCK_CHOICE_BEST_PEAKED
1661                        },
1662                    );
1663                    answers.insert(
1664                        qid.clone(),
1665                        Answer::Choice(ChoiceAnswer {
1666                            choice: best_choice,
1667                            confidence: 0.88,
1668                            probabilities: Some(probs),
1669                        }),
1670                    );
1671                }
1672                Question::Score(sq) => {
1673                    let n_levels = sq.criteria.len() as i32;
1674                    let mut matched_idx = if qid == "viability" { 3 } else { 2 };
1675
1676                    let positive_words = [
1677                        "satisfy",
1678                        "satisfaz",
1679                        "satisface",
1680                        "atende",
1681                        "passed",
1682                        "passou",
1683                        "pasó",
1684                        "pass",
1685                        "sucesso",
1686                        "éxito",
1687                        "success",
1688                        "excellent",
1689                        "exhaustively",
1690                        "complete",
1691                        "concluido",
1692                        "completado",
1693                        "proceed",
1694                    ];
1695                    let trivial_words = ["trivial", "minor", "pequeno", "menor"];
1696                    let critical_words = [
1697                        "critical",
1698                        "critico",
1699                        "crítico",
1700                        "fatal",
1701                        "disaster",
1702                        "destrutivo",
1703                        "complex",
1704                        "complexo",
1705                        "complejo",
1706                    ];
1707                    let has_deadlock_or_loop = [
1708                        "infinite loop",
1709                        "loop infinito",
1710                        "bucle infinito",
1711                        "deadlock",
1712                        "deadlock!",
1713                        "bloqueo mutuo",
1714                        "goroutines are asleep",
1715                        "mutex",
1716                    ]
1717                    .iter()
1718                    .any(|k| state_lower.contains(k));
1719
1720                    let neg_signals = ["not ok", "failed", "falhou"];
1721
1722                    let is_satisfaction_failure =
1723                        has_explicit_failure && (qid == "satisfaction" || qid == "rigor");
1724                    let is_unviable_step = qid == "viability"
1725                        && ([
1726                            "deadlock",
1727                            "circular",
1728                            "impossible",
1729                            "impossivel",
1730                            "imposible",
1731                            "doomed",
1732                            "inviavel",
1733                            "inviable",
1734                        ]
1735                        .iter()
1736                        .any(|w| state_lower.contains(w))
1737                            || has_deadlock_or_loop);
1738
1739                    if is_satisfaction_failure || is_unviable_step {
1740                        matched_idx = 1;
1741                    } else if !has_explicit_failure
1742                        && positive_words.iter().any(|w| state_lower.contains(w))
1743                        && !neg_signals.iter().any(|neg| state_lower.contains(neg))
1744                    {
1745                        matched_idx = n_levels;
1746                    } else if qid != "viability"
1747                        && trivial_words.iter().any(|w| state_lower.contains(w))
1748                        && !has_heavy_keywords
1749                    {
1750                        matched_idx = 1;
1751                    } else if qid != "viability"
1752                        && (has_heavy_keywords
1753                            || critical_words.iter().any(|w| state_lower.contains(w)))
1754                    {
1755                        matched_idx = n_levels;
1756                    }
1757
1758                    for (idx, level_label) in sq.criteria.iter().enumerate() {
1759                        let lvl_lower = level_label.to_lowercase();
1760                        let lvl_tokens: Vec<&str> = lvl_lower
1761                            .split(|c: char| !c.is_alphanumeric() && c != '_')
1762                            .filter(|s| !s.is_empty())
1763                            .collect();
1764                        if lvl_tokens.iter().any(|t| state_tokens.contains(*t))
1765                            && !(has_explicit_failure
1766                                && idx > 0
1767                                && (qid == "satisfaction" || qid == "rigor"))
1768                        {
1769                            matched_idx = (idx + 1) as i32;
1770                        }
1771                    }
1772
1773                    let levels: Vec<String> = (1..=n_levels).map(|i| i.to_string()).collect();
1774                    let probs = mock_distribution(
1775                        &levels,
1776                        &matched_idx.to_string(),
1777                        MOCK_SCORE_BEST_PEAKED,
1778                    );
1779                    answers.insert(
1780                        qid.clone(),
1781                        Answer::Score(ScoreAnswer {
1782                            score: matched_idx as f64,
1783                            confidence: 0.85,
1784                            legend: Some(serde_json::json!(sq.criteria)),
1785                            probabilities: Some(probs),
1786                        }),
1787                    );
1788                }
1789                Question::Noul(nq) => {
1790                    let inst = nq.instructions.to_lowercase();
1791                    let mut prob = 0.15;
1792
1793                    let negative_signals = [
1794                        "abort",
1795                        "abortar",
1796                        "fail",
1797                        "falha",
1798                        "fallo",
1799                        "error",
1800                        "erro",
1801                        "impossible",
1802                        "impossivel",
1803                        "imposible",
1804                        "fatal",
1805                        "circular",
1806                        "deadlock",
1807                        "dead end",
1808                        "broken",
1809                        "quebrado",
1810                        "unviable",
1811                        "inviavel",
1812                        "inviable",
1813                        "deletar",
1814                        "apagar",
1815                        "destrutivo",
1816                    ];
1817                    let positive_signals = [
1818                        "pass",
1819                        "passed",
1820                        "passou",
1821                        "pasó",
1822                        "success",
1823                        "sucesso",
1824                        "éxito",
1825                        "resolved",
1826                        "resolvido",
1827                        "resuelto",
1828                        "good",
1829                        "bom",
1830                        "valid",
1831                        "valido",
1832                        "válido",
1833                        "satisfy",
1834                        "satisfaz",
1835                        "satisface",
1836                        "atende",
1837                        "all criteria",
1838                        "todos os criterios",
1839                        "concluido",
1840                        "completado",
1841                        "complete",
1842                        "proceed",
1843                        "linear",
1844                    ];
1845                    // Structured state (E0.4) or legacy concatenated text — accept both markers.
1846                    let mut proposed_part: &str = &state_lower;
1847                    for marker in ["proposed next step:", "proposed_next_step:"] {
1848                        if let Some((_, tail)) = proposed_part.split_once(marker) {
1849                            proposed_part = tail;
1850                        }
1851                    }
1852                    let is_forward_progress = [
1853                        "implement",
1854                        "fix",
1855                        "resolve",
1856                        "correct",
1857                        "update",
1858                        "create",
1859                        "write",
1860                        "corrigir",
1861                        "implementar",
1862                        "executar",
1863                        "validar",
1864                        "corregir",
1865                    ]
1866                    .iter()
1867                    .any(|w| proposed_part.contains(w));
1868                    let is_repetitive_loop = [
1869                        "same",
1870                        "repetir",
1871                        "tentar novamente",
1872                        "intentar de nuevo",
1873                        "4a vez",
1874                        "again",
1875                        "identical",
1876                    ]
1877                    .iter()
1878                    .any(|w| proposed_part.contains(w));
1879                    let is_fatal_deadlock = [
1880                        "impossible",
1881                        "impossivel",
1882                        "imposible",
1883                        "circular",
1884                        "deadlock",
1885                        "dead end",
1886                        "inviavel",
1887                        "inviable",
1888                        "hopeless",
1889                        "fatal",
1890                    ]
1891                    .iter()
1892                    .any(|w| state_lower.contains(w));
1893                    let has_deadlock_or_loop = [
1894                        "infinite loop",
1895                        "loop infinito",
1896                        "bucle infinito",
1897                        "deadlock",
1898                        "deadlock!",
1899                        "bloqueo mutuo",
1900                        "goroutines are asleep",
1901                        "mutex",
1902                    ]
1903                    .iter()
1904                    .any(|k| state_lower.contains(k));
1905
1906                    if has_explicit_failure
1907                        && ["pass", "valid", "satisfy", "complete", "verif"]
1908                            .iter()
1909                            .any(|w| inst.contains(w))
1910                    {
1911                        prob = 0.05;
1912                    } else if is_negated_abort
1913                        && ["abort", "dead", "unviable", "destructive"]
1914                            .iter()
1915                            .any(|w| inst.contains(w))
1916                    {
1917                        prob = 0.08;
1918                    } else if (is_fatal_deadlock || has_deadlock_or_loop)
1919                        && [
1920                            "abort",
1921                            "dead",
1922                            "fail",
1923                            "urgent",
1924                            "invalid",
1925                            "unviable",
1926                            "destructive",
1927                            "dead end",
1928                        ]
1929                        .iter()
1930                        .any(|w| inst.contains(w))
1931                    {
1932                        prob = 0.88;
1933                    } else if [
1934                        "abort",
1935                        "dead",
1936                        "fail",
1937                        "urgent",
1938                        "invalid",
1939                        "unviable",
1940                        "destructive",
1941                        "dead end",
1942                    ]
1943                    .iter()
1944                    .any(|w| inst.contains(w))
1945                    {
1946                        if is_repetitive_loop {
1947                            prob = 0.88;
1948                        } else if is_forward_progress {
1949                            prob = 0.12;
1950                        } else if negative_signals.iter().any(|s| state_lower.contains(s)) {
1951                            prob = 0.85;
1952                        } else {
1953                            prob = 0.15;
1954                        }
1955                    }
1956
1957                    if !has_explicit_failure
1958                        && positive_signals.iter().any(|s| state_lower.contains(s))
1959                        && !["not ok", "failed", "falhou"]
1960                            .iter()
1961                            .any(|neg| state_lower.contains(neg))
1962                    {
1963                        if ["pass", "valid", "satisfy", "complete", "verif"]
1964                            .iter()
1965                            .any(|w| inst.contains(w))
1966                        {
1967                            prob = 0.92;
1968                        } else if ["abort", "dead", "unviable"]
1969                            .iter()
1970                            .any(|w| inst.contains(w))
1971                        {
1972                            prob = 0.08;
1973                        }
1974                    }
1975
1976                    if (is_explicit_assertion || has_deadlock_or_loop)
1977                        && (inst.contains("deterministically") || inst.contains("skip"))
1978                    {
1979                        prob = 0.05;
1980                    } else if env_missing_triggers
1981                        .iter()
1982                        .chain(flaky_triggers.iter())
1983                        .chain(["pip install", "npm install", "cargo add"].iter())
1984                        .any(|w| state_lower.contains(w))
1985                        && !(is_explicit_assertion || has_deadlock_or_loop)
1986                        && (inst.contains("deterministically") || inst.contains("skip"))
1987                    {
1988                        prob = 0.95;
1989                    }
1990
1991                    // CommandCode Jev Nudge continuation heuristics
1992                    let is_waiting_on_user = state_lower.contains('?')
1993                        || [
1994                            "waiting on user",
1995                            "need permission",
1996                            "please clarify",
1997                            "which option",
1998                            "would you like me to",
1999                            "do you want me to",
2000                            "aguardando usuário",
2001                            "preciso de permissão",
2002                            "qual opção",
2003                        ]
2004                        .iter()
2005                        .any(|w| state_lower.contains(w));
2006                    let is_done = [
2007                        "all done",
2008                        "100% passing",
2009                        "all criteria satisfied",
2010                        "tudo concluído",
2011                        "todas as etapas concluídas",
2012                        "task complete",
2013                        "konnichiwa! all done",
2014                        "all tests passed",
2015                        "tests passed (0 failed)",
2016                        "completed and verified",
2017                    ]
2018                    .iter()
2019                    .any(|w| state_lower.contains(w));
2020                    let has_unfinished_work = [
2021                        "next i'll",
2022                        "next i will",
2023                        "now i will",
2024                        "continuarei",
2025                        "a seguir vou",
2026                        "próximo passo farei",
2027                        "1 of 5",
2028                        "2 of 5",
2029                        "3 of 5",
2030                        "4 of 5",
2031                        "step 1 of",
2032                        "step 1 done",
2033                        "unfinished",
2034                        "remaining",
2035                        "todo:",
2036                        "pendente",
2037                        "partial",
2038                        "parcial",
2039                        "in progress",
2040                        "falta implementar",
2041                        "falta rodar os testes",
2042                        "sem testar",
2043                        "without running tests",
2044                        "tests not run",
2045                        "haven't run pytest",
2046                        "need to run",
2047                        "need to verify",
2048                        "to verify",
2049                        "unverified",
2050                        "run pytest",
2051                        "run cargo test",
2052                        "run npm test",
2053                        "updated file",
2054                        "edited file",
2055                        "finished editing",
2056                        "modified file",
2057                        "wrote code",
2058                        "atualizei o arquivo",
2059                        "alterei o arquivo",
2060                        "terminei de editar",
2061                        "arquivo alterado",
2062                        "next step",
2063                    ]
2064                    .iter()
2065                    .any(|w| state_lower.contains(w));
2066                    let has_no_progress = [
2067                        "no progress",
2068                        "stuck",
2069                        "same output",
2070                        "unchanged",
2071                        "repeated without change",
2072                        "sem progresso",
2073                        "mesma saída",
2074                    ]
2075                    .iter()
2076                    .any(|w| state_lower.contains(w));
2077
2078                    if qid == "waiting" || inst.contains("waiting on the user") {
2079                        prob = if is_waiting_on_user { 0.88 } else { 0.08 };
2080                    } else if qid == "progress" || inst.contains("last nudge produce real progress")
2081                    {
2082                        prob = if has_no_progress { 0.12 } else { 0.85 };
2083                    } else if qid == "nudge" || inst.contains("gentle nudge") {
2084                        if is_waiting_on_user || has_no_progress || is_done {
2085                            prob = 0.06;
2086                        } else if has_unfinished_work {
2087                            prob = 0.82;
2088                        } else {
2089                            prob = 0.20;
2090                        }
2091                    }
2092
2093                    answers.insert(qid.clone(), Answer::Noul(NoulAnswer { noul: prob }));
2094                }
2095            }
2096        }
2097
2098        JevResponse {
2099            model: model_name.to_string(),
2100            answers,
2101            usage: JevUsage {
2102                input_tokens: std::cmp::max(10, (state.len() / 4) as u32),
2103                output_tokens: 0,
2104            },
2105            is_mock: true,
2106            degraded_reason: String::new(),
2107        }
2108    }
2109}