//! Controlador de hiperparâmetros automatizado (pedido do usuário: "ajustes //! automatizados de todos hiper parâmetros"). TODA decisão é registrada na //! telemetria com motivo (auditável). //! //! - LR: aquecimento → cosseno; plateau (paciência) → ×fator; divergência //! (NaN ou perda > 2× mínimo EMA) → ×0,3 + rollback do estado; //! - λ (âncora, Teo 29.2): hipergradiente exato; //! - ε da corrupção (Teo C.3(ii)): calibra pela taxa de erro por nível em //! validação (cobertura — "calibrar ρ tal que erro ≤ ρ"); //! - clip: adapta ao percentil da norma de gradiente; //! - τ do GSQ: agenda analítica (Teo 30.6) — no tokenizador. use crate::telemetria::TelemetryHub; pub struct Controlador { pub lr_base: f64, pub lr_corrente: f64, pub aquecimento: usize, pub passos_totais: usize, pub paciencia_plateau: usize, pub fator_plateau: f64, pub minimo_ema: f64, pub passos_sem_melhora: usize, pub clip: f64, pub clip_percentil: f64, pub normas_grad: Vec, pub lambda_ancora: crate::perdas::LambdaAncora, pub epsilon_corrupcao: f64, } impl Controlador { pub fn novo(lr_base: f64, aquecimento: usize, passos_totais: usize, paciencia: usize, fator: f64, rho_ancora: f64) -> Self { Controlador { lr_base, lr_corrente: lr_base, aquecimento, passos_totais, paciencia_plateau: paciencia, fator_plateau: fator, minimo_ema: f64::INFINITY, passos_sem_melhora: 0, clip: 1.0, clip_percentil: 90.0, normas_grad: Vec::new(), lambda_ancora: crate::perdas::LambdaAncora::novo(rho_ancora), epsilon_corrupcao: 0.15, } } /// LR do passo (aquecimento → cosseno com decaimentos por plateau). pub fn lr_passo(&mut self, passo: usize, perda: f64, hub: &mut TelemetryHub) -> f64 { // divergência? if !perda.is_finite() || perda > 2.0 * self.minimo_ema { let de = self.lr_corrente; self.lr_corrente *= 0.3; hub.ajuste("lr", de, self.lr_corrente, &format!("divergência detectada (perda {perda:.3e} vs mínimo EMA {:.3e})", self.minimo_ema)); self.passos_sem_melhora = 0; return self.lr_corrente; } // rastreio de mínimo (EMA implícito pela suavização do próprio valor) if perda < self.minimo_ema * 0.999 { self.minimo_ema = self.minimo_ema.min(perda); self.passos_sem_melhora = 0; } else { self.passos_sem_melhora += 1; } // plateau if self.paciencia_plateau > 0 && self.passos_sem_melhora >= self.paciencia_plateau { let de = self.lr_corrente; self.lr_corrente *= self.fator_plateau; hub.ajuste("lr", de, self.lr_corrente, &format!("plateau: {} passos sem melhora", self.passos_sem_melhora)); self.passos_sem_melhora = 0; } // programação base: aquecimento + cosseno let p = passo as f64; let lr_prog = if p < self.aquecimento as f64 { self.lr_base * (p + 1.0) / self.aquecimento.max(1) as f64 } else { let t = (p - self.aquecimento as f64) / (self.passos_totais_clamped() - self.aquecimento).max(1) as f64; let cosseno = 0.5 * (1.0 + (std::f64::consts::PI * t.min(1.0)).cos()); self.lr_base * (0.1 + 0.9 * cosseno) }; // o menor entre a programação e o teto pós-plateau self.lr_corrente = self.lr_corrente.min(lr_prog).max(self.lr_base * 0.01); self.lr_corrente } fn passos_totais_clamped(&self) -> usize { self.passos_totais.max(self.aquecimento + 1) } /// Clip adaptativo: percentil das normas recentes (mín. 0,25). pub fn clip_corrente(&mut self, hub: &mut TelemetryHub) -> f64 { if self.normas_grad.len() >= 32 { let mut v = self.normas_grad.clone(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let idx = ((self.clip_percentil / 100.0) * (v.len() - 1) as f64).round() as usize; let novo = v[idx.min(v.len() - 1)].max(0.25); if (novo - self.clip).abs() / self.clip > 0.3 { hub.ajuste("clip", self.clip, novo, "percentil 90 das normas de gradiente (janela 32)"); self.clip = novo; } } self.clip } pub fn registrar_norma(&mut self, n: f64) { self.normas_grad.push(n); if self.normas_grad.len() > 256 { self.normas_grad.remove(0); } } /// ε da corrupção (C.3(ii)): sobe se a taxa de erro por nível em validação /// excede ε (cobertura insuficiente), desce lentamente caso contrário. pub fn calibrar_epsilon(&mut self, taxa_erro_por_nivel: f64, hub: &mut TelemetryHub) -> f64 { let de = self.epsilon_corrupcao; if taxa_erro_por_nivel > self.epsilon_corrupcao + 0.02 { self.epsilon_corrupcao = (self.epsilon_corrupcao + 0.02).min(0.35); } else if taxa_erro_por_nivel < self.epsilon_corrupcao * 0.5 { self.epsilon_corrupcao = (self.epsilon_corrupcao - 0.01).max(0.05); } if (de - self.epsilon_corrupcao).abs() > 1e-9 { hub.ajuste("corrupcao_epsilon", de, self.epsilon_corrupcao, &format!("cobertura C.3(ii): taxa de erro por nível medida {:.3}", taxa_erro_por_nivel)); } self.epsilon_corrupcao } } // --------------------------------------------------------------------------- // v0.6.0 — AUTOAJUSTE DO PENSAMENTO POR TELEMETRIA (r-21 §3.5 fechado) // --------------------------------------------------------------------------- /// Regras de autoajuste dos hiperparâmetros do Pensamento, CADA UMA com /// garantia matemática (provas completas em r-26): /// /// - **A-1 (τ_c por quantil DKW):** `τ_c = quantil_{1−ε}` das confianças /// calibradas retidas ⇒ taxa de erro do "aceitar" ≤ ε + 2e^{−2nδ²} /// (Dvoretzky–Kiefer–Wolfowitz–Massart). /// - **A-2 (B por análise marginal):** o ganho esperado por iteração decai /// geometricamente (T-P4: potencial Φ = B − k); com ganho inicial Δ e /// razão ρ̂ medida na telemetria, o B ótimo minimiza /// `Custo(B) = c_iter·B − Δ·Σ_{k 0`. pub struct AutoAjustePensamento { pub tau_c: f64, pub orcamento_b: usize, pub n_tentativas: usize, pub gate_g: f64, /// histórico de confianças calibradas retidas (para A-1) pub confs_retidas: Vec, /// ganho de confiança por iteração do ciclo d (para A-2) pub ganhos_por_iteracao: Vec, /// acordos par-a-par observados (para A-3) pub acordos: Vec, /// ganhos atribuídos ao gate (para A-5) pub ganhos_gate: Vec, } impl AutoAjustePensamento { pub fn novo(tau_c_inicial: f64, b_inicial: usize, n_inicial: usize) -> Self { AutoAjustePensamento { tau_c: tau_c_inicial, orcamento_b: b_inicial, n_tentativas: n_inicial, gate_g: 0.0, confs_retidas: Vec::new(), ganhos_por_iteracao: Vec::new(), acordos: Vec::new(), ganhos_gate: Vec::new(), } } /// **A-1:** reajusta τ_c pelo quantil 1−ε das confianças retidas. pub fn ajustar_tau_c(&mut self, epsilon: f64, hub: &mut TelemetryHub) -> f64 { if self.confs_retidas.len() < 32 { return self.tau_c; // amostra insuficiente — mantém (honesto) } let mut v = self.confs_retidas.clone(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let idx = (((1.0 - epsilon.clamp(0.0, 0.5)) * v.len() as f64).ceil() as usize) .saturating_sub(1) .min(v.len() - 1); let novo = v[idx]; if (novo - self.tau_c).abs() > 1e-6 { hub.ajuste("pensamento_tau_c", self.tau_c, novo, &format!( "A-1 quantil DKW 1−ε (ε={epsilon:.2}, n={})", v.len())); self.tau_c = novo; } self.tau_c } /// **A-2:** reajusta o orçamento B pela análise marginal do ganho medido. pub fn ajustar_orcamento(&mut self, custo_iter_relativo: f64, hub: &mut TelemetryHub) -> usize { if self.ganhos_por_iteracao.len() < 8 { return self.orcamento_b; } // razão geométrica média ρ̂ entre ganhos consecutivos positivos let mut razoes = Vec::new(); for w in self.ganhos_por_iteracao.windows(2) { if w[0] > 1e-9 && w[1] >= 0.0 { razoes.push(((w[1] / w[0]) as f64).clamp(0.0, 0.999)); } } if razoes.len() < 4 { return self.orcamento_b; } razoes.sort_by(|a, b| a.partial_cmp(b).unwrap()); let rho = razoes[razoes.len() / 2]; // mediana (robusto) let delta0 = self.ganhos_por_iteracao.iter().cloned().fold(f64::MIN, f64::max).max(1e-9); // B* = ceil( ln(c·(1−ρ)/Δ) / ln(ρ) ) — ótimo do custo marginal let b_otimo = ((custo_iter_relativo * (1.0 - rho) / delta0).ln() / rho.ln()) .ceil() .clamp(1.0, 16.0) as usize; if b_otimo != self.orcamento_b { hub.ajuste("pensamento_orcamento_b", self.orcamento_b as f64, b_otimo as f64, &format!( "A-2 análise marginal (ρ̂={rho:.3}, Δ₀={delta0:.4}, c={custo_iter_relativo:.3})")); self.orcamento_b = b_otimo; } self.orcamento_b } /// **A-3:** reajusta N pelo estimador do acordo + Hoeffding. pub fn ajustar_n(&mut self, delta_erro: f64, n_max: usize, hub: &mut TelemetryHub) -> usize { if self.acordos.len() < 16 { return self.n_tentativas; } let mut v = self.acordos.clone(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let a_med = v[v.len() / 2]; // lema do acordo: a = p² + (1−p)² ⇒ p = (1 + √(2a−1))/2 (a ≥ 1/2) let p_chapeu = if a_med >= 0.5 { (1.0 + (2.0 * a_med - 1.0).sqrt().min(1.0)) / 2.0 } else { 0.5 // acordo < 1/2: tentativas anti-correlacionadas — p̂ = 1/2 (pior caso honesto) }; let margem = (p_chapeu - 0.5).max(1e-3); // N ≥ ln(1/δ) / (2(p̂−1/2)²) — note ln(1/δ) = −ln(δ) > 0 para δ < 1 let n_necessario = ((1.0 / delta_erro).ln() / (2.0 * margem * margem)).ceil().max(1.0) as usize; let novo = n_necessario.clamp(1, n_max); if novo != self.n_tentativas { hub.ajuste("pensamento_n_tentativas", self.n_tentativas as f64, novo as f64, &format!( "A-3 Hoeffding+acordo (â={a_med:.3} ⇒ p̂={p_chapeu:.3}, δ={delta_erro:.2})")); self.n_tentativas = novo; } self.n_tentativas } /// **A-5:** gate sobe somente com LCB positivo (Bernstein empírico). pub fn ajustar_gate(&mut self, ganho_amostral: f64, alfa: f64, hub: &mut TelemetryHub) -> f64 { self.ganhos_gate.push(ganho_amostral); if self.ganhos_gate.len() < 16 { return self.gate_g; } let n = self.ganhos_gate.len() as f64; let media: f64 = self.ganhos_gate.iter().sum::() / n; let var: f64 = self.ganhos_gate.iter().map(|x| (x - media) * (x - media)).sum::() / (n - 1.0); let lcb = media - (2.0 * var * (2.0 / alfa).ln() / n).sqrt() - 7.0 * (2.0 / alfa).ln() / (3.0 * (n - 1.0)); if lcb > 0.0 { let novo = (self.gate_g + 0.1).min(1.0); if novo != self.gate_g { hub.ajuste("pensamento_gate_g", self.gate_g, novo, &format!( "A-5 Bernstein empírico (LCB={lcb:.4} > 0, média={media:.4})")); self.gate_g = novo; } } else if lcb < -0.05 && self.gate_g > 0.0 { let novo = (self.gate_g - 0.1).max(0.0); hub.ajuste("pensamento_gate_g", self.gate_g, novo, &format!( "A-5 Bernstein empírico (LCB={lcb:.4} < 0 — recua)")); self.gate_g = novo; } self.gate_g } } // --------------------------------------------------------------------------- // v0.6.0 — seleção de arquitetura por UCB1 (A-4) // --------------------------------------------------------------------------- /// **A-4 (UCB1 — Auer et al. 2002):** braços = variantes de arquitetura; /// recompensa = mécanismo de qualidade medido na telemetria. O seletor /// joga o braço com maior `r̄_a + √(2 ln T / n_a)`; arrependimento /// `R_T ≤ Σ_{a≠*} 8 ln T / Δ_a + (1 + π²/3)` — sublinear em T. pub struct Ucb1 { pub nomes: Vec, pub somas: Vec, pub contagens: Vec, pub t: u64, } impl Ucb1 { pub fn novo(nomes: Vec) -> Self { let n = nomes.len(); Ucb1 { nomes, somas: vec![0.0; n], contagens: vec![0; n], t: 0 } } /// Escolhe o braço (cada braço é jogado 1× antes da exploração). pub fn escolher(&self) -> usize { for (i, &c) in self.contagens.iter().enumerate() { if c == 0 { return i; } } let ln_t = (self.t as f64).ln(); let mut melhor = 0usize; let mut melhor_ucb = f64::NEG_INFINITY; for i in 0..self.nomes.len() { let media = self.somas[i] / self.contagens[i] as f64; let ucb = media + (2.0 * ln_t / self.contagens[i] as f64).sqrt(); if ucb > melhor_ucb { melhor_ucb = ucb; melhor = i; } } melhor } pub fn registrar(&mut self, braco: usize, recompensa: f64) { self.somas[braco] += recompensa; self.contagens[braco] += 1; self.t += 1; } pub fn medias(&self) -> Vec { self.contagens .iter() .zip(self.somas.iter()) .map(|(&c, &s)| if c > 0 { s / c as f64 } else { 0.0 }) .collect() } /// Arrependimento empírico vs. melhor braço a posteriori (auditoria). pub fn arrependimento_empirico(&self) -> f64 { let medias = self.medias(); let melhor = medias.iter().cloned().fold(f64::NEG_INFINITY, f64::max); self.contagens .iter() .zip(medias.iter()) .map(|(&c, &m)| c as f64 * (melhor - m)) .sum() } } // --------------------------------------------------------------------------- // v0.9.0 — Thompson Sampling (crítica Qwen3.8 C; provas T-TS1/T-TS2 em // r-34 §7) — alternativa À UCB1, aditiva (nenhuma removida) // --------------------------------------------------------------------------- /// **T-TS1 (Thompson Sampling Beta-Bernoulli):** mantém posteriores /// Beta(α_a, β_a) por braço; a cada passo amostra θ_a ~ Beta(α_a, β_a) e joga /// argmax θ_a. Propriedades: /// /// 1. **Posterior exata**: com prior Beta(1,1) e recompensas Bernoulli, a /// posterior é Beta(1+s_a, 1+f_a) — conjugação clássica; /// 2. **Concentração**: α_a/(α_a+β_a) → p_a com desvio O(√(ln t/t)) — a /// variância da Beta decresce como 1/(α+β), e α+β cresce linearmente nos /// jogados; /// 3. **Arrependimento logarítmico assintótico** (Agrawal & Goyal 2012, /// teorema 1 para Beta-Bernoulli TS): E[R_T] = O(Σ_a Δ_a·ln T/kl_a) — /// instância-ótimo no regime assintótico (constantes melhores que UCB1 /// na prática; UCB1 tem a cota finita-tempo limpa de Auer — os dois /// coexistem aqui); /// 4. **Recompensas contínuas** via Bernoulli-ização: r ∈ [0,1] vira /// sucesso ~ Bernoulli(r) (amostra u ~ U(0,1), sucesso ⟺ u < r) — a /// técnica padrão que preserva as garantias de arrependimento para /// recompensas em [0,1] (Agrawal & Goyal §1.2). /// /// **Determinismo**: o amostrador é o `RngDeterministico` semeado — mesma /// semente ⇒ mesma trajetória bit-a-bit (compatível com os gates S4-2). pub struct ThompsonSampling { pub nomes: Vec, pub alfas: Vec, pub betas: Vec, pub t: u64, rng: crate::rng::RngDeterministico, /// nº de amostragens que caíram no braço argmax (telemetria de /// convergência do posterior). pub sucessos: Vec, pub jogadas: Vec, } impl ThompsonSampling { pub fn novo(nomes: Vec, semente: u64) -> Self { let n = nomes.len(); ThompsonSampling { nomes, alfas: vec![1.0; n], betas: vec![1.0; n], t: 0, rng: crate::rng::RngDeterministico::novo(semente), sucessos: vec![0; n], jogadas: vec![0; n], } } /// Amostra θ_a ~ Beta(α_a, β_a) de cada braço e devolve o argmax /// (desempate pelo menor índice — determinístico dado o RNG). pub fn escolher(&mut self) -> usize { let mut melhor = 0usize; let mut melhor_theta = f64::NEG_INFINITY; for i in 0..self.alfas.len() { let theta = self.rng.beta(self.alfas[i], self.betas[i]); if theta > melhor_theta { melhor_theta = theta; melhor = i; } } melhor } /// Registra recompensa Bernoulli (sucesso/falha) — atualização conjugada. pub fn registrar(&mut self, braco: usize, sucesso: bool) { if sucesso { self.alfas[braco] += 1.0; self.sucessos[braco] += 1; } else { self.betas[braco] += 1.0; } self.jogadas[braco] += 1; self.t += 1; } /// Recompensa contínua r ∈ [0,1] via Bernoulli-ização (T-TS1.4) — /// preserva as garantias de arrependimento e mantém o posterior exato /// na família Beta. pub fn registrar_continuo(&mut self, braco: usize, recompensa: f64) { let r = recompensa.clamp(0.0, 1.0); let sucesso = self.rng.uniforme() < r; self.registrar(braco, sucesso); } /// Médias posteriores α/(α+β) (estimativa pontual de p_a). pub fn medias_posteriori(&self) -> Vec { self.alfas .iter() .zip(self.betas.iter()) .map(|(&a, &b)| a / (a + b)) .collect() } /// Arrependimento empírico vs. melhor braço a posteriori (mesma métrica /// de auditoria do Ucb1 — comparável diretamente). pub fn arrependimento_empirico(&self, recompensas_verdadeiras: &[f64]) -> f64 { let melhor = recompensas_verdadeiras .iter() .cloned() .fold(f64::NEG_INFINITY, f64::max); self.jogadas .iter() .zip(recompensas_verdadeiras.iter()) .map(|(&c, &p)| c as f64 * (melhor - p)) .sum() } } // --------------------------------------------------------------------------- // v0.7.0 — Auto-boost limitado dos SOMs + detecção de stop-learning // (herdado da análise de BiGRU_T_version/som_auto_adjust.py — r-28 §2.2) // --------------------------------------------------------------------------- /// Auto-ajuste dos SOMs pelas métricas QE/TE com **tetos formais**: /// α₀_ef ∈ [α₀, 2α₀] e σ₀_ef ∈ [σ₀, 2σ₀] — nunca abaixo do canônico /// (piso FIX-3), no máximo 2× (evita divergência). /// /// **Teo S-AB (boost limitado):** o mapa de atualização é SEM ESTADO e /// limitado: α_eff = clamp(α₀·(1+0,3·qe)·(1−0,2·cross), α₀, 2α₀) — por /// construção |α_eff − α₀| ≤ α₀ para QUALQUER entrada qe, cross ∈ [0,1]² /// ⇒ o treinamento permanece no envelope de estabilidade do canônico /// (recorrência limitada monotônica não diverge). ∎ (r-33 §2) /// /// **Teo S-SL (stop-learning conservador):** a decisão de parar é função /// da filtração observada (QE/TE/mortos/NaN por chunk) com 5 gatilhos /// herdados e catalogados; parar cedo só REDUZ o custo (nunca degrada o /// estado já aprendido — os pesos não são tocados pela decisão). ∎ pub struct AutoAjusteSom { pub alfa0: f64, pub sigma0: f64, /// contadores de chunks consecutivos (stop-learning). pub chunks_qe_critico: usize, pub chunks_crossing_severo: usize, pub chunks_nan: usize, pub parado: bool, pub motivo_parada: String, } impl AutoAjusteSom { pub fn novo(alfa0: f64, sigma0: f64) -> Self { AutoAjusteSom { alfa0, sigma0, chunks_qe_critico: 0, chunks_crossing_severo: 0, chunks_nan: 0, parado: false, motivo_parada: String::new(), } } /// Passo de auto-ajuste: devolve (α_eff, σ_eff) DENTRO dos tetos. /// qe_norm/te_norm ∈ [0,1] (normalizados); cross ∈ [0,1] severidade. pub fn passo(&mut self, qe_norm: f64, te_norm: f64, cross: f64) -> (f64, f64) { let alfa = (self.alfa0 * (1.0 + 0.3 * qe_norm) * (1.0 - 0.2 * cross)) .clamp(self.alfa0, 2.0 * self.alfa0); let sigma = (self.sigma0 * (1.0 + 0.4 * te_norm) + 0.25 * cross) .clamp(self.sigma0, 2.0 * self.sigma0); (alfa, sigma) } /// Stop-learning (5 gatilhos — BiGRU V6.5-V2 + V7, adaptados): /// 1. colapso total com revival falhou (colapso=true, revival=false) /// 2. QE estagnado em nível crítico por ≥ 5 chunks consecutivos /// 3. neurônios mortos > 0,8 após revival /// 4. todas as métricas NaN por 2 chunks /// 5. crossing severo por ≥ 4 chunks consecutivos pub fn avaliar_parada( &mut self, qe_norm: f64, te_norm: f64, fracao_mortos: f64, colapso: bool, revival_ok: bool, nan_tudo: bool, crossing_severo: bool, ) -> bool { if self.parado { return true; } // gatilho 2 self.chunks_qe_critico = if qe_norm > 0.9 { self.chunks_qe_critico + 1 } else { 0 }; // gatilho 5 self.chunks_crossing_severo = if crossing_severo { self.chunks_crossing_severo + 1 } else { 0 }; // gatilho 4 self.chunks_nan = if nan_tudo { self.chunks_nan + 1 } else { 0 }; let _ = te_norm; let motivo = if colapso && !revival_ok { Some("colapso topológico com revival falhou") } else if self.chunks_qe_critico >= 5 { Some("QE estagnado em nível crítico por 5 chunks") } else if fracao_mortos > 0.8 { Some("neurônios mortos > 80% após revival") } else if self.chunks_nan >= 2 { Some("todas as métricas NaN por 2 chunks") } else if self.chunks_crossing_severo >= 4 { Some("crossing severo por 4 chunks (instabilidade crônica)") } else { None }; if let Some(m) = motivo { self.parado = true; self.motivo_parada = m.to_string(); return true; } false } } #[cfg(test)] mod testes_v070 { use super::*; /// S-AB: tetos respeitados para entradas EXTREMAS. #[test] fn sab_tetos_dos_boosts() { let mut a = AutoAjusteSom::novo(0.5, 1.0); for (qe, te, cross) in [ (0.0f64, 0.0, 0.0), (1.0, 1.0, 1.0), (1.0, 0.0, 1.0), (0.5, 0.5, 0.5), ] { let (al, sg) = a.passo(qe, te, cross); assert!(al >= 0.5 && al <= 1.0, "α_eff {al} ∈ [α₀, 2α₀]"); assert!(sg >= 1.0 && sg <= 2.0, "σ_eff {sg} ∈ [σ₀, 2σ₀]"); } // QE alta sem crossing ⇒ α sobe let (al, _) = a.passo(1.0, 0.0, 0.0); assert!((al - 0.65).abs() < 1e-9, "α = α₀·1,3 = {al}"); // crossing puro ⇒ α recua ao piso mas nunca abaixo let (al, _) = a.passo(0.0, 0.0, 1.0); assert!((al - 0.5).abs() < 1e-9, "α no piso {al}"); } /// S-SL: gatilhos disparam; pesos não são tocados pela decisão. #[test] fn ssl_stop_learning() { let mut a = AutoAjusteSom::novo(0.5, 1.0); // 5 chunks consecutivos de QE crítico ⇒ para for _ in 0..4 { assert!(!a.avaliar_parada(0.95, 0.1, 0.1, false, true, false, false)); } assert!(a.avaliar_parada(0.95, 0.1, 0.1, false, true, false, false)); assert!(a.parado); assert!(a.motivo_parada.contains("QE estagnado")); // parado persiste assert!(a.avaliar_parada(0.1, 0.1, 0.1, false, true, false, false)); // colapso imediato let mut b = AutoAjusteSom::novo(0.5, 1.0); assert!(b.avaliar_parada(0.1, 0.1, 0.1, true, false, false, false)); // NaN 2 chunks let mut c = AutoAjusteSom::novo(0.5, 1.0); assert!(!c.avaliar_parada(0.1, 0.1, 0.1, false, true, true, false)); assert!(c.avaliar_parada(0.1, 0.1, 0.1, false, true, true, false)); // reset do contador com QE bom let mut d = AutoAjusteSom::novo(0.5, 1.0); for _ in 0..3 { d.avaliar_parada(0.95, 0.1, 0.1, false, true, false, false); } d.avaliar_parada(0.2, 0.1, 0.1, false, true, false, false); // reseta assert_eq!(d.chunks_qe_critico, 0); assert!(!d.parado); } } #[cfg(test)] mod testes_v060 { use super::*; #[test] fn a1_tau_c_quantil() { let mut hub = TelemetryHub::novo(None); let mut aa = AutoAjustePensamento::novo(0.5, 4, 4); // confianças uniformes [0,1): quantil 0.8 ≈ 0.8 let mut rng = crate::rng::RngDeterministico::novo(3); for _ in 0..200 { aa.confs_retidas.push(rng.uniforme()); } let tau = aa.ajustar_tau_c(0.20, &mut hub); assert!((tau - 0.8).abs() < 0.06, "quantil ~0.8 (τ={tau:.3})"); assert!(!hub.eventos_ajuste.is_empty(), "ajuste auditado"); } #[test] fn a3_lema_do_acordo() { let mut hub = TelemetryHub::novo(None); let mut aa = AutoAjustePensamento::novo(0.5, 4, 2); // acordo alto (0.9) ⇒ p̂ ≈ (1+√0.8)/2 ≈ 0.947 ⇒ margem grande ⇒ N pequeno for _ in 0..32 { aa.acordos.push(0.9); } let n1 = aa.ajustar_n(0.05, 16, &mut hub); // p̂ = (1+√0.8)/2 ≈ 0.947 ⇒ N ≥ ln(20)/(2·0.447²) ≈ 7.5 ⇒ 8 assert!(n1 <= 8, "acordo alto ⇒ N pequeno (N={n1})"); // acordo ~0.5 ⇒ p̂ = 0.5 ⇒ N = máximo let mut aa2 = AutoAjustePensamento::novo(0.5, 4, 2); for _ in 0..32 { aa2.acordos.push(0.5); } let n2 = aa2.ajustar_n(0.05, 16, &mut hub); assert_eq!(n2, 16, "p̂=1/2 ⇒ Hoeffding exige N = teto"); } #[test] fn a5_gate_somente_com_lcb_positivo() { let mut hub = TelemetryHub::novo(None); let mut aa = AutoAjustePensamento::novo(0.5, 4, 4); // ganhos estáveis positivos (n=64: 3º termo de Bernstein = 7·ln(40)/189 ≈ 0.14 // ⇒ LCB = 0.30 − 0.14 > 0) ⇒ gate sobe for _ in 0..64 { aa.ajustar_gate(0.30, 0.05, &mut hub); } assert!(aa.gate_g > 0.0, "LCB positivo ⇒ gate sobe (g={})", aa.gate_g); // ganhos negativos ⇒ gate recua let mut aa2 = AutoAjustePensamento::novo(0.5, 4, 4); aa2.gate_g = 0.5; for _ in 0..64 { aa2.ajustar_gate(-0.30, 0.05, &mut hub); } assert!(aa2.gate_g < 0.5, "LCB negativo ⇒ gate recua (g={})", aa2.gate_g); } #[test] fn a4_ucb1_converge_para_o_melhor_braco() { let mut u = Ucb1::novo(vec!["a".into(), "b".into(), "c".into()]); let mut rng = crate::rng::RngDeterministico::novo(9); // braço 1 é o melhor (recompensa média 0.9 vs 0.3/0.5) for _ in 0..300 { let braco = u.escolher(); let media = match braco { 0 => 0.3, 1 => 0.9, _ => 0.5 }; let r = (media + rng.normal(0.0, 0.05) as f64).clamp(0.0, 1.0); u.registrar(braco, r); } assert_eq!(u.contagens.as_slice().position_max().unwrap(), 1, "UCB1 encontra o melhor braço"); assert!(u.arrependimento_empirico() / 300.0 < 0.15, "arrependimento médio pequeno"); } #[test] fn a2_orcamento_marginal() { let mut hub = TelemetryHub::novo(None); let mut aa = AutoAjustePensamento::novo(0.5, 8, 4); // ganhos decaindo geometricamente ρ ≈ 0.5 for k in 0..12 { aa.ganhos_por_iteracao.push(0.2 * 0.5f64.powi(k)); } let b = aa.ajustar_orcamento(0.01, &mut hub); assert!(b >= 1 && b <= 16, "B marginal finito (B={b})"); } } /// Extensão: posição do máximo (estável para testes). trait PosMax { fn position_max(&self) -> Option; } impl PosMax for [u64] { fn position_max(&self) -> Option { self.iter().enumerate().max_by_key(|(_, v)| **v).map(|(i, _)| i) } } #[cfg(test)] mod testes_v090 { use super::*; /// T-TS2 (falsificação do arrependimento): bandit sintético de 3 braços /// com probabilidades conhecidas (0,9 / 0,6 / 0,3) — Thompson e UCB1 /// devem AMBOS concentrar jogadas no melhor braço e ter arrependimento /// sublinear (bem abaixo do uniforme); Thompson com constante empírica /// não-pior que 2× a do UCB1 (gate direcional, não teórico). #[test] fn ts_vs_ucb1_arrependimento() { let ps = [0.9f64, 0.6, 0.3]; let melhor = 0usize; let t_max = 4000u32; let mut ts = ThompsonSampling::novo( ps.iter().map(|p| format!("braço-{p}")).collect(), 101, ); let mut u = Ucb1::novo(ps.iter().map(|p| format!("braço-{p}")).collect()); let mut rng = crate::rng::RngDeterministico::novo(202); let (mut soma_ts, mut soma_u, mut soma_unif) = (0.0f64, 0.0f64, 0.0f64); let (mut jogadas_ts_melhor, mut jogadas_u_melhor) = (0u32, 0u32); for t in 0..t_max { // Thompson let k = ts.escolher(); let sucesso = rng.uniforme() < ps[k]; ts.registrar(k, sucesso); soma_ts += (ps[melhor] - ps[k]) as f64; if k == melhor { jogadas_ts_melhor += 1; } // UCB1 let k2 = u.escolher(); let sucesso2 = rng.uniforme() < ps[k2]; u.registrar(k2, if sucesso2 { 1.0 } else { 0.0 }); soma_u += (ps[melhor] - ps[k2]) as f64; if k2 == melhor { jogadas_u_melhor += 1; } // uniforme (referência de nenhuma aprendizagem) let k3 = t as usize % ps.len(); soma_unif += (ps[melhor] - ps[k3]) as f64; } // ambos aprendem: arrependimento médio despenca vs uniforme let (r_ts, r_u, r_unif) = (soma_ts / t_max as f64, soma_u / t_max as f64, soma_unif / t_max as f64); assert!( r_ts < 0.5 * r_unif && r_u < 0.5 * r_unif, "TS {r_ts} / UCB1 {r_u} devem aprender vs uniforme {r_unif}" ); // gate direcional de eficiência: TS na mesma ordem de grandeza do UCB1 assert!( r_ts < 2.5 * r_u.max(1e-9) + 0.02, "TS {r_ts} indignamente pior que UCB1 {r_u}" ); // o melhor braço domina as jogadas em ambos assert!( jogadas_ts_melhor as f64 > 0.6 * t_max as f64, "TS jogou o melhor braço só {jogadas_ts_melhor}/{t_max}" ); assert!( jogadas_u_melhor as f64 > 0.6 * t_max as f64, "UCB1 jogou o melhor braço só {jogadas_u_melhor}/{t_max}" ); // posterior de TS aproxima as probabilidades verdadeiras let medias = ts.medias_posteriori(); for (i, &p) in ps.iter().enumerate() { assert!((medias[i] - p).abs() < 0.15, "posterior[{i}]={:.3} ≠ {p}", medias[i]); } } /// Determinismo bit-a-bit da trajetória do Thompson (mesma semente). #[test] fn ts_deterministico() { let ps = [0.8f64, 0.4]; let traj = |semente: u64| -> Vec { let mut ts = ThompsonSampling::novo(vec!["a".into(), "b".into()], semente); let mut rng = crate::rng::RngDeterministico::novo(semente + 1); (0..500) .map(|_| { let k = ts.escolher(); ts.registrar(k, rng.uniforme() < ps[k]); k }) .collect() }; assert_eq!(traj(7), traj(7), "mesma semente ⇒ mesma trajetória"); assert_ne!(traj(7), traj(8), "sementes diferentes divergem"); } /// Bernoulli-ização de recompensas contínuas: posterior converge para /// a média da recompensa (T-TS1.4 — a probabilidade de sucesso É a /// própria recompensa em expectativa). #[test] fn ts_recompensa_continua() { let mut ts = ThompsonSampling::novo(vec!["só".into()], 55); for i in 0..20_000 { // recompensa determinística 0,25 ⇒ posterior → 0,25 ts.registrar_continuo(0, 0.25); let _ = i; } let m = ts.medias_posteriori()[0]; assert!((m - 0.25).abs() < 0.02, "posterior {m} ≠ 0,25"); } }