//! arvore_pesos.rs — **Árvore Enraizada com Arestas Duplas** indexada dos //! pesos do projeto (requisito v0.7.0: "arquitetura de pesos em árvore e //! subárvores indexada" + ajuste bidirecional do quebra-cabeça). //! //! Estrutura (r-28 §4.2, provas r-30): //! - **Nós** = módulos do projeto (raiz "khtst"; filhos: tronco, quantizador, //! som, jepa, moe, pensamento, verificador, fnh, nlp, nlg, acoplador). //! - **Partição de índices**: cada nó possui um intervalo [ini, fim) do índice //! global de parâmetros — disjuntos e cobrindo [0, N) (T-A1). //! - **Arestas duplas**: cada relação pai↔filho é UMA aresta com DOIS //! enlaces direcionados (pai→filho = regulação; filho→pai = crédito) — //! `vizinho` e `aresta_de` são O(1) por índice de adjacência (T-A4). //! - **Fundo-topo**: agregação de métricas (telemetria/crédito) sobe — //! soma exata por indução (T-A2). //! - **Topo-fundo**: regulação desce com decaimento por profundidade — //! causal por filtração de níveis e terminante por potencial (T-A3). use std::collections::HashMap; /// Um nó da árvore — um módulo com sua fatia do índice de parâmetros. #[derive(Clone, Debug)] pub struct NoArvore { pub nome: String, pub pai: Option, pub filhos: Vec, /// Intervalo [ini, fim) no índice global de parâmetros (partição). pub ini: usize, pub fim: usize, /// Profundidade (raiz = 0). pub profundidade: usize, /// Métricas agregadas do nó (a própria + descendentes). pub metricas: HashMap, } /// Enlace direcionado de uma aresta dupla. #[derive(Clone, Copy, Debug)] pub struct Enlace { /// id do PAR (compartilhado pelos dois enlaces da aresta dupla). pub id_par: usize, pub de: usize, pub para: usize, } /// Árvore de pesos indexada com arestas duplas. pub struct ArvorePesos { pub nos: Vec, por_nome: HashMap, /// Enlaces (2 por aresta: regulação desce, crédito sobe). pub enlaces: Vec, /// Índice de adjacência: nó → enlaces incidentes — O(1). adj: Vec>, /// Fluxo de crédito por enlace (sob na direção filho→pai). pub credito: Vec, /// Regulação por enlace (desce na direção pai→filho). pub regulacao: Vec, pub n_params: usize, } /// Layout padrão do projeto KHTST_RUST v0.7.0 — (nome, n_params, filhos). /// A ordem define os intervalos da partição (T-A1). pub const LAYOUT_PADRAO: &[(&str, usize)] = &[ ("khtst", 0), // raiz — sem parâmetros próprios (agrega) ("tronco", 1), ("quantizador", 1), ("som", 1), ("jepa", 1), ("moe", 1), ("pensamento", 1), ("verificador", 1), ("fnh", 1), ("nlp", 1), ("nlg", 1), ("acoplador", 1), ]; impl ArvorePesos { /// Constrói a árvore padrão: raiz "khtst" com os módulos como filhos /// diretos (profundidade 1) — subárvores podem ser anexadas depois. /// `contagens`: número de parâmetros por módulo (default 1 quando 0). pub fn nova(contagens: &HashMap) -> Self { let mut nos: Vec = Vec::new(); let mut por_nome = HashMap::new(); let mut pos = 0usize; for (i, (nome, _)) in LAYOUT_PADRAO.iter().enumerate() { let n = if i == 0 { 0 } else { contagens.get(*nome).copied().unwrap_or(1).max(1) }; let no = NoArvore { nome: nome.to_string(), pai: if i == 0 { None } else { Some(0) }, filhos: Vec::new(), ini: pos, fim: pos + n, profundidade: if i == 0 { 0 } else { 1 }, metricas: HashMap::new(), }; pos = no.fim; por_nome.insert(nome.to_string(), i); if i != 0 { nos[0].filhos.push(i); } nos.push(no); } let n_nos = nos.len(); // arestas duplas: raiz↔filho let mut enlaces = Vec::new(); let mut adj = vec![Vec::new(); n_nos]; for (id_par, &f) in nos[0].filhos.iter().enumerate() { // enlace pai→filho (regulação desce) adj[0].push(enlaces.len()); enlaces.push(Enlace { id_par, de: 0, para: f }); // enlace filho→pai (crédito sobe) adj[f].push(enlaces.len()); enlaces.push(Enlace { id_par, de: f, para: 0 }); } let n_enlaces = enlaces.len(); ArvorePesos { nos, por_nome, enlaces, adj, credito: vec![0.0; n_enlaces], regulacao: vec![0.0; n_enlaces], n_params: pos, } } /// Consulta O(1) por nome (T-A4). pub fn no(&self, nome: &str) -> Option { self.por_nome.get(nome).copied() } pub fn n_nos(&self) -> usize { self.nos.len() } /// Enlace direcionado `de`→`para` (aresta dupla — T-A1 simetria). pub fn enlace(&self, de: usize, para: usize) -> Option { self.adj[de] .iter() .copied() .find(|&e| self.enlaces[e].para == para) } /// Enlaces incidentes ao nó (ambas as direções) — O(1) pelo índice. pub fn incidentes(&self, no: usize) -> &[usize] { &self.adj[no] } /// FatiA do índice de parâmetros da subárvore enraizada em `no` /// (o próprio intervalo quando não há filhos anexados). pub fn subarvore(&self, no: usize) -> (usize, usize) { // varre a subárvore em profundidade e devolve o envoltório dos // intervalos (a partição é contígua por construção pós-ordem se os // filhos forem anexados em ordem — aqui calculamos o envoltório) let (mut ini, mut fim) = (self.nos[no].ini, self.nos[no].fim); for &f in &self.nos[no].filhos { let (i2, f2) = self.subarvore(f); ini = ini.min(i2); fim = fim.max(f2); } (ini, fim) } /// Caminho do nó até a raiz (sobe pelos pais — termina: pai único, raiz /// sem pai; T-A3 terminação). pub fn caminho_ate_raiz(&self, no: usize) -> Vec { let mut caminho = vec![no]; let mut atual = no; while let Some(p) = self.nos[atual].pai { caminho.push(p); atual = p; } caminho } /// Anexa um filho com ARESTA DUPLA ao nó `pai` (nome, n_params) — /// mantém o índice de adjacência e a partição do índice global (o /// intervalo do novo nó continua o índice). Devolve o índice do novo nó. /// v0.8.0: habilita subárvores profundas (teste T-A7b) sem reconstruir /// a árvore — a pós-ordem iterativa não depende da profundidade. pub fn anexar_filho(&mut self, pai: usize, nome: &str, n_params: usize) -> usize { let idx = self.nos.len(); let prof = self.nos[pai].profundidade + 1; let ini = self.n_params; let fim = ini + n_params.max(1); self.nos.push(NoArvore { nome: nome.to_string(), pai: Some(pai), filhos: Vec::new(), ini, fim, profundidade: prof, metricas: HashMap::new(), }); self.por_nome.insert(nome.to_string(), idx); self.nos[pai].filhos.push(idx); // aresta dupla pai↔filho (novo id_par) let id_par = self.enlaces.len() / 2; self.adj.push(Vec::new()); self.adj[pai].push(self.enlaces.len()); self.enlaces.push(Enlace { id_par, de: pai, para: idx }); self.adj[idx].push(self.enlaces.len()); self.enlaces.push(Enlace { id_par, de: idx, para: pai }); // 1 entrada por ENLACE (2 por aresta dupla) — credito/regulacao são // indexadas por enlace, não por aresta self.credito.push(0.0); self.credito.push(0.0); self.regulacao.push(0.0); self.regulacao.push(0.0); self.n_params = fim; idx } /// **Fundo-topo**: agrega a métrica dos filhos no pai (pós-ordem — /// T-A2: a soma na raiz = soma global de todas as folhas + próprias). /// Devolve o total agregado na raiz. /// v0.8.0: **ITERATIVA** (proposta Qwen-3.8 nº 2 VALIDADA, com correções /// de robustez) — pilha explícita no heap, SEM recursão e SEM clones de /// `filhos`: O(V+E) tempo, O(V) espaço, zero alocação por nó; segura /// para árvores profundas degeneradas (T-A7b). Equivalência BIT-A-BIT /// com a recursiva (T-A7): mesma ordem de pós-ordem e mesma ordem de /// soma — prova em r-33 §3. pub fn agregar_fundo_topo(&mut self, metrica: &str) -> f64 { match self.agregar_pos_ordem(metrica) { Ok(v) => v, Err(e) => { // estrutura malformada (ciclo/filho duplicado): a recursiva // somaria duas vezes ou não terminaria — aqui é explícito log::warn!("agregar_fundo_topo: {e}"); f64::NAN } } } /// Pós-ordem iterativa com verificação estrutural — erro em ciclo ou /// filho duplicado. O flag `empilhado` é marcado NO MOMENTO DO PUSH: /// um nó empilhado duas vezes (duplicata no mesmo pai, ou re-entrada de /// ciclo) é recusado imediatamente — a marcação pós-expansão deixaria /// escapar o caso "pai lista o filho duas vezes" (o 2º push ocorre /// antes da expansão do 1º — errata da própria correção, r-33 §3.4). fn agregar_pos_ordem(&mut self, metrica: &str) -> Result { let n = self.nos.len(); if n == 0 { return Ok(0.0); } // fase 1: ordem de visita (pós-ordem: filhos ANTES do pai) let mut estado = vec![0u8; n]; // 0 = novo, 1 = filhos empilhados let mut empilhado = vec![false; n]; // marcado NO PUSH (unicidade) let mut pilha: Vec = Vec::with_capacity(n); let mut pos_ordem: Vec = Vec::with_capacity(n); pilha.push(0usize); empilhado[0] = true; while let Some(&atual) = pilha.last() { if estado[atual] == 0 { estado[atual] = 1; for &f in self.nos[atual].filhos.iter().rev() { if f >= n { return Err(format!("filho {f} fora do intervalo (nó {atual})")); } if empilhado[f] { return Err(format!( "filho {f} duplicado ou ciclo detectado no nó {atual}" )); } empilhado[f] = true; pilha.push(f); } } else { pilha.pop(); pos_ordem.push(atual); } } // fase 2: processa folhas primeiro (a pós-ordem já é fundo-topo) for &no in pos_ordem.iter() { let mut total = self.nos[no].metricas.get(metrica).copied().unwrap_or(0.0); for &f in self.nos[no].filhos.iter() { let v_filho = self.nos[f].metricas.get(metrica).copied().unwrap_or(0.0); total += v_filho; // MESMA ordem da recursiva (bit-a-bit — T-A7) if let Some(e) = self.enlace(f, no) { self.credito[e] = v_filho; // crédito sobe filho→pai } } self.nos[no].metricas.insert(metrica.to_string(), total); } Ok(self.nos[0].metricas.get(metrica).copied().unwrap_or(0.0)) } /// Referência RECURSIVA (v0.6.0) — mantida como oráculo de equivalência /// do teste T-A7 e documentação do ganho (não usa em produção: clone de /// `filhos` por nó + quadros de pilha O(profundidade)). pub fn agregar_fundo_topo_rec(&mut self, metrica: &str) -> f64 { self.agregar_rec(0, metrica) } fn agregar_rec(&mut self, no: usize, metrica: &str) -> f64 { let mut total = self.nos[no].metricas.get(metrica).copied().unwrap_or(0.0); let filhos = self.nos[no].filhos.clone(); for f in filhos { let v = self.agregar_rec(f, metrica); // crédito do filho sobe pelo enlace filho→pai if let Some(e) = self.enlace(f, no) { self.credito[e] = v; } total += v; } self.nos[no].metricas.insert(metrica.to_string(), total); total } /// **Topo-fundo**: regulação desce do nó `origem` com decaimento /// λ^k por nível de profundidade relativa (T-A3: causal — desce por /// níveis da filtração da árvore; terminante — profundidade finita). /// v0.8.0: ITERATIVA (pré-ordem com pilha explícita — mesmo ganho da /// agregação: sem clones, sem recursão; mesma ordem de escrita). pub fn regular_topo_fundo(&mut self, origem: usize, delta: f64, lambda: f64) { let base = self.nos[origem].profundidade; let mut pilha = vec![origem]; while let Some(u) = pilha.pop() { // k do PAI u (semântica v0.6.0 exata: a aresta u→f usa λ^k(u)) let k = self.nos[u].profundidade.saturating_sub(base); let d = delta * lambda.powi(k as i32); for &f in self.nos[u].filhos.iter().rev() { if let Some(e) = self.enlace(u, f) { self.regulacao[e] = d; // desce pelo enlace pai→filho } pilha.push(f); } } } /// **Ajuste indexado regulado** (quebra-cabeça do Pensamento — r-31): /// aplica `delta` (com teto por componente |δ| ≤ delta_max) na métrica /// `metrica` do nó `no` e propaga o crédito fundo-topo até a raiz. /// Devolve o delta efetivamente aplicado (clamped). pub fn aplicar_ajuste_indexado( &mut self, no: usize, metrica: &str, delta: f64, delta_max: f64, ) -> f64 { let d = delta.clamp(-delta_max, delta_max); let atual = self.nos[no].metricas.get(metrica).copied().unwrap_or(0.0); self.nos[no].metricas.insert(metrica.to_string(), atual + d); d } /// Verificação dos invariantes estruturais (T-A1) — usada nos gates. pub fn invariantes_ok(&self) -> Result<(), String> { // (i) pai único exceto raiz; profundidade consistente for (i, no) in self.nos.iter().enumerate() { if i == 0 { if no.pai.is_some() { return Err("raiz não pode ter pai".into()); } } else if let Some(p) = no.pai { if p >= self.nos.len() { return Err(format!("pai {p} fora do intervalo (nó {i})")); } if !self.nos[p].filhos.contains(&i) { return Err(format!("pai de {i} não o lista como filho")); } } else { return Err(format!("não-raiz {i} sem pai")); } } // (ii) partição: intervalos disjuntos dentro de [0, n_params) let mut marcas = vec![false; self.n_params]; for no in &self.nos { if no.ini >= no.fim && no.nome != "khtst" { return Err(format!("nó {} com intervalo vazio", no.nome)); } for k in no.ini..no.fim { if marcas[k] { return Err(format!("índice {k} particionado duas vezes")); } marcas[k] = true; } } if marcas.iter().any(|m| !m) { return Err("partição não cobre todo o índice".into()); } // (iii) simetria das arestas duplas: (u,v) ∈ E ⇔ (v,u) ∈ E, mesmo id_par for e in &self.enlaces { let espelhado = self .enlace(e.para, e.de) .ok_or_else(|| format!("aresta ({},{}) sem espelho", e.de, e.para))?; if self.enlaces[espelhado].id_par != e.id_par { return Err(format!( "espelho de ({},{}) com id_par diferente", e.de, e.para )); } } // (iv) simetria pai↔filho (v0.8.0 — T-A8): todo filho listado por u // deve ter pai = u (captura o 2-ciclo A↔B anexado à raiz, que as // checagens (i)-(iii) deixam passar) for (u, no) in self.nos.iter().enumerate() { for &f in &no.filhos { if self.nos[f].pai != Some(u) { return Err(format!( "nó {u} lista filho {f} cujo pai é {:?} (assimetria)", self.nos[f].pai )); } } } // (v) alcançabilidade (v0.8.0 — T-A9): todo nó atingível da raiz — // com pai único + simetria (iv), isso equivale a ACICLICO+conectado let mut visto = vec![false; self.nos.len()]; let mut fila = vec![0usize]; visto[0] = true; let mut alcancados = 1usize; while let Some(u) = fila.pop() { for &f in &self.nos[u].filhos { if !visto[f] { visto[f] = true; alcancados += 1; fila.push(f); } } } if alcancados != self.nos.len() { return Err(format!( "{} nós inalcançáveis da raiz (ciclo desconectado?)", self.nos.len() - alcancados )); } Ok(()) } } #[cfg(test)] mod testes { use super::*; fn arvore_exemplo() -> ArvorePesos { let mut contagens = HashMap::new(); contagens.insert("tronco".to_string(), 40usize); contagens.insert("quantizador".to_string(), 10); contagens.insert("som".to_string(), 6); contagens.insert("jepa".to_string(), 6); contagens.insert("moe".to_string(), 5); contagens.insert("pensamento".to_string(), 8); contagens.insert("verificador".to_string(), 3); contagens.insert("fnh".to_string(), 4); contagens.insert("nlp".to_string(), 5); contagens.insert("nlg".to_string(), 5); contagens.insert("acoplador".to_string(), 2); ArvorePesos::nova(&contagens) } /// T-A1: invariantes — pai único, partição disjunta completa, simetria. #[test] fn ta1_invariantes() { let a = arvore_exemplo(); a.invariantes_ok().expect("invariantes T-A1"); assert_eq!(a.n_params, 94); } /// T-A2: agregação fundo-topo = soma global (indução pós-ordem). #[test] fn ta2_agregacao_fundo_topo() { let mut a = arvore_exemplo(); // métricas locais por nó let valores = [ ("tronco", 3.0), ("quantizador", 5.0), ("som", 1.0), ("jepa", 2.0), ("moe", 4.0), ("pensamento", 7.0), ("verificador", 1.0), ("fnh", 6.0), ("nlp", 2.0), ("nlg", 3.0), ("acoplador", 1.0), ]; let mut esperado = 0.0; for (nome, v) in valores { let no = a.no(nome).unwrap(); a.nos[no].metricas.insert("punicao".into(), v); esperado += v; } let total = a.agregar_fundo_topo("punicao"); assert!((total - esperado).abs() < 1e-12, "T-A2: {total} ≠ {esperado}"); let raiz = a.no("khtst").unwrap(); let na_raiz = a.nos[raiz].metricas["punicao"]; assert!((na_raiz - esperado).abs() < 1e-12, "T-A2: raiz {na_raiz}"); // crédito nos enlaces filho→pai = valor agregado do filho let f = a.no("fnh").unwrap(); let e = a.enlace(f, raiz).unwrap(); assert!((a.credito[e] - 6.0).abs() < 1e-12, "T-A2: crédito do fnh"); } /// T-A3: regulação topo-fundo — decaimento λ^k e causalidade por níveis. #[test] fn ta3_regulacao_topo_fundo() { let mut a = arvore_exemplo(); let raiz = a.no("khtst").unwrap(); a.regular_topo_fundo(raiz, 0.8, 0.5); // filhos diretos da raiz: k=0 ⇒ regulação = 0,8 (λ^0) for &f in &a.nos[raiz].filhos.clone() { let e = a.enlace(raiz, f).unwrap(); assert!((a.regulacao[e] - 0.8).abs() < 1e-12, "T-A3: nível 1"); } assert!(a.invariantes_ok().is_ok()); } /// T-A4: consultas O(1) — por nome, enlace e incidentes. #[test] fn ta4_consultas_o1() { let a = arvore_exemplo(); let f = a.no("fnh").expect("fnh"); assert_eq!(a.nos[f].nome, "fnh"); let raiz = a.no("khtst").unwrap(); assert!(a.enlace(f, raiz).is_some()); assert!(a.enlace(raiz, f).is_some()); assert_eq!(a.incidentes(f).len(), 1); // 1 aresta dupla = 1 enlace incidente listado? não: 1 enlace filho→pai // subárvore do fnh = seu próprio intervalo let (ini, fim) = a.subarvore(f); assert_eq!((ini, fim), (a.nos[f].ini, a.nos[f].fim)); // caminho fnh→raiz assert_eq!(a.caminho_ate_raiz(f), vec![f, raiz]); } /// T-A5: ajuste indexado regulado com teto — clamping simétrico. #[test] fn ta5_ajuste_indexado_com_teto() { let mut a = arvore_exemplo(); let f = a.no("pensamento").unwrap(); let d1 = a.aplicar_ajuste_indexado(f, "confianca", 0.3, 0.1); assert!((d1 - 0.1).abs() < 1e-12, "T-A5: teto superior"); let d2 = a.aplicar_ajuste_indexado(f, "confianca", -50.0, 0.1); assert!((d2 + 0.1).abs() < 1e-12, "T-A5: teto inferior"); let v = a.nos[f].metricas["confianca"]; assert!((v - 0.0).abs() < 1e-12, "T-A5: 0,1 − 0,1 = 0"); } /// **T-A7 (v0.8.0):** equivalência BIT-A-BIT da agregação iterativa com /// a recursiva em árvores aleatórias (mesma pós-ordem, mesma ordem de /// soma — prova r-33 §3.3). Proposta Qwen-3.8 nº 2 validada. #[test] fn ta7_equivalencia_bit_a_bit() { let mut rng = crate::rng::RngDeterministico::novo(909); for tentativa in 0..8u32 { let mut a = arvore_exemplo(); // anexa subárvores aleatórias (ramificação E profundidade) for j in 0..12u32 { let pai = rng.inteiro(a.n_nos()); let nome = format!("extra_{tentativa}_{j}"); a.anexar_filho(pai, &nome, 1 + rng.inteiro(4)); } // métricas locais aleatórias (registradas para repor depois) let locais: Vec = (0..a.n_nos()) .map(|no| { let v = rng.normal(0.0, 2.0); a.nos[no].metricas.insert("punicao".into(), v); v }) .collect(); let esperado: f64 = locais.iter().sum(); a.invariantes_ok().expect("árvore aleatória válida"); // iterativa let total_it = a.agregar_fundo_topo("punicao"); let credito_it = a.credito.clone(); // repõe as métricas locais e roda a recursiva (oráculo v0.6.0) for (no, v) in locais.iter().enumerate() { a.nos[no].metricas.insert("punicao".into(), *v); } let total_rec = a.agregar_fundo_topo_rec("punicao"); let credito_rec = a.credito.clone(); assert_eq!( total_it.to_bits(), total_rec.to_bits(), "T-A7: total bit-a-bit (tentativa {tentativa}: {total_it} vs {total_rec})" ); for (i, (x, y)) in credito_it.iter().zip(credito_rec.iter()).enumerate() { assert_eq!(x.to_bits(), y.to_bits(), "T-A7: crédito[{i}] bit-a-bit"); } assert!( (total_it - esperado).abs() < 1e-9, "T-A7: soma global (tentativa {tentativa})" ); } } /// **T-A7b (v0.8.0):** cadeia degenerada de 100.000 nós — a iterativa /// completa SEM estourar a pilha (a recursiva exigiria ~100k quadros, /// excedendo o limite padrão de 8 MiB — risco documentado da v0.6.0; /// por isso ela NÃO é executada aqui). #[test] fn ta7b_cadeia_profunda_100k() { let mut a = arvore_exemplo(); let mut pai = 1usize; // tronco for i in 0..100_000u32 { pai = a.anexar_filho(pai, &format!("c{i}"), 1); } assert_eq!(a.n_nos(), 100_000 + 12); a.nos[pai].metricas.insert("conf".into(), 1.0); let total = a.agregar_fundo_topo("conf"); assert!((total - 1.0).abs() < 1e-12, "T-A7b: total {total}"); // o crédito sobe INTEIRO por toda a cadeia (T-A2) let e = a.enlace(pai, a.nos[pai].pai.unwrap()).unwrap(); assert!((a.credito[e] - 1.0).abs() < 1e-12, "T-A7b: crédito na folha"); assert!(a.invariantes_ok().is_ok(), "T-A7b: invariantes na cadeia"); } /// **T-A7c (v0.8.0):** regulação iterativa preserva a semântica λ^k do /// PAI (v0.6.0) em subárvores de profundidade 3. #[test] fn ta7c_regular_iterativa_equivalente() { let mut a = arvore_exemplo(); let n1 = a.anexar_filho(1, "sub1", 2); // profundidade 2 (sob o tronco) let n2 = a.anexar_filho(n1, "sub2", 2); // profundidade 3 let _n3 = a.anexar_filho(n2, "sub3", 2); // profundidade 4 let raiz = 0usize; a.regular_topo_fundo(raiz, 0.8, 0.5); // aresta raiz→tronco: k(raiz) = 0 ⇒ λ⁰ let e = a.enlace(raiz, 1).unwrap(); assert!((a.regulacao[e] - 0.8).abs() < 1e-12, "T-A7c: nível raiz"); // aresta tronco→sub1: k(tronco) = 1 ⇒ 0,8·0,5 let e1 = a.enlace(1, n1).unwrap(); assert!((a.regulacao[e1] - 0.4).abs() < 1e-12, "T-A7c: nível 1"); // aresta sub1→sub2: k(sub1) = 2 ⇒ 0,8·0,25 let e2 = a.enlace(n1, n2).unwrap(); assert!((a.regulacao[e2] - 0.2).abs() < 1e-12, "T-A7c: nível 2"); assert!(a.invariantes_ok().is_ok()); } /// **T-A8 (v0.8.0):** detecção de estruturas malformadas — filho /// duplicado (a iterativa recusa; a recursiva somaria DUAS vezes) e /// ciclos (checagens (iv) simetria e (v) alcançabilidade — que as /// checagens (i)-(iii) da v0.6.0 deixavam passar). #[test] fn ta8_deteccao_ciclos_e_duplicatas() { let mut a = arvore_exemplo(); // (a) filho duplicado: raiz lista o tronco duas vezes a.nos[0].filhos.push(1); let total = a.agregar_fundo_topo("punicao"); assert!(total.is_nan(), "T-A8: duplicata deve recusar (NaN), não somar 2×"); a.nos[0].filhos.pop(); assert!(a.invariantes_ok().is_ok(), "T-A8: restaurada"); // (b) assimetria pai↔filho: tronco lista o quantizador (pai = raiz) a.nos[1].filhos.push(2); let err = a.invariantes_ok().unwrap_err(); assert!(err.contains("assimetria"), "T-A8(iv): {err}"); a.nos[1].filhos.pop(); // (c) 2-ciclo desconectado x↔y: passa (i)-(iv), capturado por (v) let x = a.nos.len(); let y = x + 1; a.nos.push(NoArvore { nome: "x".into(), pai: Some(y), filhos: vec![y], ini: 94, fim: 95, profundidade: 9, metricas: HashMap::new(), }); a.nos.push(NoArvore { nome: "y".into(), pai: Some(x), filhos: vec![x], ini: 95, fim: 96, profundidade: 9, metricas: HashMap::new(), }); a.n_params = 96; let err = a.invariantes_ok().unwrap_err(); assert!(err.contains("inalcançáveis"), "T-A8(v): {err}"); } }