File size: 18,288 Bytes
4dbc907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! nlg.rs — **UnidadeNLG** (especialista de geração/saída) + decodificador
//! especulativo em árvore (Medusa) — port Rust de
//! `khtst-multimodal-ptbr/src/khtst/nlg/{unidade_nlg.py,
//! decodificador_especulativo.py}` e `aurora-multimodal-ptbr/src/aurora/nlg/`
//! (doc 15 §1 / doc 14; Teos 15.1, 14.1, 14.2).
//!
//! Papel (Def. 15.2): a NLG **não tem parâmetros da lm_head** — gera
//! MODULANDO o estado oculto (decisão de projeto: evita duplicar
//! 16384×192):
//! (i) vetor de PLANO (tema/estilo) por pooling atencional do prompt;
//! (ii) re-ranking de coerência: penaliza candidatos com bigrama não visto
//!      no contexto recente (tabela EMA de bigramas, hashing trick,
//!      O(|V_cand|));
//! (iii) condicionamento de estilo: h_dec = h + W_estilo·p, p ∈ R^8 —
//!      W_estilo ZERO no nascimento (Teo N-5 = port do Teo 15.1).
//!
//! Decodificador especulativo (doc 14): rascunho = cabeças MTP com logits
//! EMPATADOS; verificação em UM forward com aceitação TÍPICA (Heim 2022,
//! eq. 14.2: aceita sse −log p(t) − H ≤ τ·H); poda por cabeça
//! c_k = min(c, max(1, c−k)); EMA de estatísticas (0,9/0,1); guarda contra
//! laço infinito (teto de árvore). **Teo E-1 (inocuidade — port do 14.1):**
//! a política com correção residual preserva EXATAMENTE p_alvo — a
//! especulação nunca muda a distribuição, só o custo. **Teo E-2 (speedup):**
//! S = (E[aceitos]+1)/(1+|T|/V_ef) > 1 sse ρ > |T|/(V_ef+|T|).

use crate::camadas::Linear;
use candle_core::{Device, Result, Tensor, Var};
use std::collections::HashMap;

pub const N_EIXOS_ESTILO: usize = 8;

/// Unidade NLG — especialista de saída (port fiel, v0.7.0).
pub struct UnidadeNlg {
    pub d: usize,
    /// (i) pooling do plano: projeção para pesos de atenção sobre o prompt.
    pub pooler: Linear, // (d → d), sem viés
    /// (iii) eixos de estilo aprendíveis (logits de controle).
    pub eixos: Var, // (8,)
    /// (iii) W_estilo: p ∈ R^8 → deslocamento em d — ZERO no nascimento.
    pub w_estilo: Var, // (d, 8)
    /// (ii) tabela EMA de bigramas (hashing trick) para re-ranking.
    pub tab_bigramas: Vec<f32>,
    pub tam_tab: usize,
    pub beta_tab: f64,
    /// telemetria local
    pub ultimo_score_coerencia: f64,
}

impl UnidadeNlg {
    pub fn nova(d: usize, tam_tab: usize, dev: &Device) -> Result<Self> {
        let we = Tensor::zeros((d, N_EIXOS_ESTILO), candle_core::DType::F32, dev)?;
        let eixos = Tensor::zeros((N_EIXOS_ESTILO,), candle_core::DType::F32, dev)?;
        Ok(UnidadeNlg {
            d,
            pooler: Linear::sem_vies(d, d, 0.02, dev)?,
            eixos: Var::from_tensor(&eixos)?,
            w_estilo: Var::from_tensor(&we)?,
            tab_bigramas: vec![0.0; tam_tab],
            tam_tab,
            beta_tab: 0.98,
            ultimo_score_coerencia: 0.0,
        })
    }

    pub fn vars(&self) -> Vec<Var> {
        vec![
            self.pooler.w.clone(),
            self.eixos.clone(),
            self.w_estilo.clone(),
        ]
    }

    /// (i) Vetor de PLANO: pooling atencional do prompt.
    /// h_prompt: (B, Tp, d) → plano (B, d).
    pub fn plano(&self, h_prompt: &Tensor) -> Result<Tensor> {
        let scores = self.pooler.fwd(h_prompt)?; // (B, Tp, d)
        let pesos = candle_nn::ops::softmax(&scores.mean(2)?, candle_core::D::Minus1)?; // (B, Tp)
        // Σ_t pesos_t · h_t
        let p = pesos.unsqueeze(2)?.broadcast_mul(h_prompt)?;
        p.sum(1)
    }

    /// (iii) Condicionamento de estilo: h_dec = h + W_estilo·p.
    /// `estilo`: (8,) em [0,1]; None ⇒ σ(eixos) (controle aprendível).
    /// W_estilo = 0 no nascimento ⇒ h_dec = h bit-a-bit (Teo N-5).
    pub fn condicionar(&self, h: &Tensor, estilo: Option<&Tensor>) -> Result<Tensor> {
        let p = match estilo {
            Some(p) => p.clone(),
            None => candle_nn::ops::sigmoid(self.eixos.as_tensor())?,
        };
        // deslocamento (..., d) = W_estilo · p
        let desloc = self
            .w_estilo
            .as_tensor()
            .matmul(&p.reshape((N_EIXOS_ESTILO, 1))?)?
            .reshape((1, self.d))?;
        let forma = h.dims().to_vec();
        let n: usize = forma.iter().take(forma.len().saturating_sub(1)).product::<usize>().max(1);
        let h2 = h.reshape((n, forma[forma.len() - 1]))?;
        let d2 = desloc.broadcast_as((n, self.d))?;
        let out = h2.broadcast_add(&d2)?;
        out.reshape(forma)
    }

    /// Hash de bigrama (hashing trick — FNV-1a 32 bits mod tabela).
    fn hash_bigrama(a: u32, b: u32, tam: usize) -> usize {
        let mut h: u32 = 2166136261;
        for x in [a, b] {
            h ^= x;
            h = h.wrapping_mul(16777619);
        }
        (h as usize) % tam
    }

    /// (ii) Atualiza a tabela EMA com os bigramas do CONTEXTO (o que o
    /// modelo já emitiu/viu — coerência dinâmica).
    pub fn observar_contexto(&mut self, tokens: &[u32]) {
        for j in 1..tokens.len() {
            let idx = Self::hash_bigrama(tokens[j - 1], tokens[j], self.tam_tab);
            let atual = self.tab_bigramas[idx];
            self.tab_bigramas[idx] =
                (atual as f64 * self.beta_tab + 1.0 * (1.0 - self.beta_tab)) as f32;
        }
    }

    /// (ii) Score de coerência de um candidato: fração de bigramas JÁ
    /// vistos no contexto (EMA > 0,02 conta como visto) — quanto maior,
    /// mais coerente com o histórico. O(v) para v tokens do candidato.
    pub fn score_coerencia(&mut self, candidato: &[u32]) -> f64 {
        if candidato.len() < 2 {
            self.ultimo_score_coerencia = 1.0;
            return 1.0;
        }
        let mut vistos = 0usize;
        for j in 1..candidato.len() {
            let idx = Self::hash_bigrama(candidato[j - 1], candidato[j], self.tam_tab);
            // EMA com UMA observação = (1−β) = 0,02 — o limiar de "visto"
            // fica ABAIXO disso (0,015) para contar a primeira ocorrência
            if self.tab_bigramas[idx] >= 0.015 {
                vistos += 1;
            }
        }
        let s = vistos as f64 / (candidato.len() - 1) as f64;
        self.ultimo_score_coerencia = s;
        s
    }

    /// (ii) Re-ranking de coerência: dado um conjunto de candidatos com
    /// log-probabilidades do modelo, reordena por λ·logp + (1−λ)·coerência.
    /// Devolve o índice do vencedor e os scores (telemetria).
    pub fn reranking_coerencia(
        &mut self,
        candidatos: &[Vec<u32>],
        logps: &[f64],
        lambda: f64,
    ) -> (usize, Vec<f64>) {
        let n = candidatos.len();
        let mut scores: Vec<f64> = Vec::with_capacity(n);
        let mut coers: Vec<f64> = Vec::with_capacity(n);
        for c in candidatos {
            coers.push(self.score_coerencia(c));
        }
        // normaliza logp por z-score robusto (mediana)
        let mut ordenado = logps.to_vec();
        ordenado.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let med = ordenado[n / 2];
        for (i, c) in candidatos.iter().enumerate() {
            let lp = logps[i] - med;
            scores.push(lambda * lp + (1.0 - lambda) * coers[i]);
        }
        let mut melhor = 0usize;
        let mut mv = f64::NEG_INFINITY;
        for (i, s) in scores.iter().enumerate() {
            if *s > mv {
                mv = *s;
                melhor = i;
            }
        }
        (melhor, scores)
    }

    /// Telemetria local.
    pub fn telemetria(&self) -> HashMap<String, f64> {
        let mut m = HashMap::new();
        m.insert("nlg_score_coerencia".into(), self.ultimo_score_coerencia);
        m.insert("nlg_beta_tab".into(), self.beta_tab);
        m
    }
}

// ---------------------------------------------------------------------------
// Decodificador especulativo em árvore (Medusa — doc 14)
// ---------------------------------------------------------------------------

/// Estatísticas EMA do decodificador (0,9/0,1 — absorvidas do aurora).
#[derive(Clone, Debug, Default)]
pub struct StatsEspeculativo {
    pub n_chamadas: usize,
    pub tokens_por_passo: f64,
    pub tamanho_arvore_medio: f64,
    pub aceitacao_media: f64, // ρ̂
    pub speedup_estimado: f64,
}

/// Decodificador especulativo: rascunho = M cabeças MTP (logits empatados),
/// alvo = política de geração normal (logits + temperatura).
pub struct DecodificadorEspeculativo {
    pub c_por_cabeca: usize,
    pub tau_tipico: f64,
    pub teto_arvore: usize,
    /// custo relativo do rascunho por token (cabeça MTP linear ≈ 0,1 do
    /// forward do alvo — Teo E-2: S = (E[aceitos]+1)/(1+c_d)).
    pub custo_rascunho: f64,
    pub stats: StatsEspeculativo,
}

impl DecodificadorEspeculativo {
    pub fn novo(c_por_cabeca: usize, tau_tipico: f64, teto_arvore: usize) -> Self {
        DecodificadorEspeculativo {
            c_por_cabeca,
            tau_tipico,
            teto_arvore,
            custo_rascunho: 0.1,
            stats: StatsEspeculativo::default(),
        }
    }

    /// **Aceitação típica** (Heim 2022, eq. 14.2): aceita o candidato t sse
    /// −log p_alvo(t) − H ≤ τ·H, com H = entropia de p_alvo (conjunto
    /// típico). Finita por construção (log-softmax estável).
    pub fn aceitacao_tipica(
        &self,
        logits_alvo: &Tensor,  // (K,) — logits do ALVO
        candidatos: &[usize], // índices dos candidatos do rascunho
    ) -> Result<Vec<bool>> {
        let log_p = candle_nn::ops::log_softmax(logits_alvo, candle_core::D::Minus1)?;
        let p = log_p.exp()?;
        // H = −Σ p log p (estável: log-softmax finito)
        let h = (&p * &log_p)?.sum_all()?.to_scalar::<f32>()? as f64 * -1.0;
        let k = logits_alvo.dims()[0] as f64;
        let h_norm = if k > 1.0 { h / k.ln() } else { 0.0 };
        let lpv = log_p.flatten_all()?.to_vec1::<f32>()?;
        Ok(candidatos
            .iter()
            .map(|&t| {
                let neg_lp = -(lpv[t] as f64);
                // conjunto típico: distância da entropia normalizada ≤ τ
                (neg_lp - h_norm).abs() <= self.tau_tipico * h_norm.max(1e-6)
            })
            .collect())
    }

    /// **Poda por cabeça** (doc 14 §2): c_k = min(c, max(1, c−k)) candidatos
    /// top-c por cabeça k — árvore SEM duplicatas, respeitando o teto.
    pub fn podar_cabecas(&self, logits_rascunho: &[Tensor]) -> Vec<usize> {
        let mut candidatos: Vec<usize> = Vec::new();
        for (k, lg) in logits_rascunho.iter().enumerate() {
            let take = self.c_por_cabeca.min(self.c_por_cabeca.saturating_sub(k).max(1));
            let v = lg.flatten_all().unwrap().to_vec1::<f32>().unwrap();
            let mut idx: Vec<usize> = (0..v.len()).collect();
            idx.sort_by(|&a, &b| v[b].partial_cmp(&v[a]).unwrap());
            for t in idx.into_iter().take(take) {
                if !candidatos.contains(&t) {
                    candidatos.push(t);
                }
                if candidatos.len() >= self.teto_arvore {
                    return candidatos;
                }
            }
        }
        candidatos
    }

    /// **Verificação com correção residual (Teo E-1 — inocuidade).**
    /// Dados os logits do ALVO (K,) e o candidato do rascunho `t_rasc`:
    /// aceito ⇒ usa t_rasc; rejeitado ⇒ amostra da residual
    /// p_res(v) = p_alvo(v)·1[v não aceitável]/(1−Σ_aceitos p_alvo).
    /// A distribuição resultante é EXATAMENTE p_alvo (prova r-32 E-1).
    /// Aqui: versão determinística por argmax da residual (demonstração
    /// auditável; a versão estocástica troca argmax por amostragem).
    pub fn verificar_e_corrigir(
        &mut self,
        logits_alvo: &Tensor,
        candidatos: &[usize],
    ) -> Result<(usize, bool)> {
        let aceitos = self.aceitacao_tipica(logits_alvo, candidatos)?;
        // primeiro aceito em ordem de rascunho
        if let Some(&t) = candidatos.iter().zip(aceitos.iter()).find(|(_, a)| **a).map(|(t, _)| t) {
            self.registrar(true, candidatos.len());
            return Ok((t, true));
        }
        // correção residual: argmax de p_alvo fora do conjunto típico aceito
        let p = candle_nn::ops::softmax(logits_alvo, candle_core::D::Minus1)?;
        let pv = p.flatten_all()?.to_vec1::<f32>()?;
        let mut melhor = 0usize;
        let mut mv = f32::NEG_INFINITY;
        for (t, &pt) in pv.iter().enumerate() {
            let penalizado = candidatos.contains(&t);
            let ajustado = if penalizado { f32::NEG_INFINITY } else { pt };
            if ajustado > mv {
                mv = ajustado;
                melhor = t;
            }
        }
        self.registrar(false, candidatos.len());
        Ok((melhor, false))
    }

    /// EMA de estatísticas (0,9/0,1) + speedup do Teo E-2:
    /// S = (E[aceitos]+1)/(1+c_d) — ganho sse ρ > c_d/(1+c_d).
    fn registrar(&mut self, aceito: bool, tam_arvore: usize) {
        let a = 0.9;
        self.stats.n_chamadas += 1;
        let acc = if aceito { 1.0 } else { 0.0 };
        self.stats.aceitacao_media = a * self.stats.aceitacao_media + (1.0 - a) * acc;
        self.stats.tamanho_arvore_medio =
            a * self.stats.tamanho_arvore_medio + (1.0 - a) * tam_arvore as f64;
        let tokens = if aceito { 1.0 } else { 0.0 };
        self.stats.tokens_por_passo = a * self.stats.tokens_por_passo + (1.0 - a) * tokens;
        let num = self.stats.aceitacao_media + 1.0;
        let den = 1.0 + self.custo_rascunho;
        self.stats.speedup_estimado = num / den;
    }
}

#[cfg(test)]
mod testes {
    use super::*;

    /// N-5 (port do Teo 15.1): nascimento neutro da NLG — W_estilo = 0 ⇒
    /// condicionar(h) = h bit-a-bit para qualquer h e qualquer estilo p.
    #[test]
    fn n5_nascimento_neutro_nlg() -> Result<()> {
        let dev = Device::Cpu;
        let u = UnidadeNlg::nova(32, 4096, &dev)?;
        let h = Tensor::randn(0f32, 1f32, (2, 5, 32), &dev)?;
        let p = Tensor::randn(0f32, 1f32, (N_EIXOS_ESTILO,), &dev)?;
        let p01 = candle_nn::ops::sigmoid(&p)?;
        let h2 = u.condicionar(&h, Some(&p01))?;
        let dif = (&h - &h2)?.abs()?.max_all()?.to_scalar::<f32>()?;
        assert_eq!(dif, 0.0, "N-5: W_estilo=0 ⇒ h_dec = h bit-a-bit (dif {dif})");
        // plano: forma correta e finito
        let pl = u.plano(&h)?;
        assert_eq!(pl.dims(), vec![2, 32]);
        assert!(pl.abs()?.max_all()?.to_scalar::<f32>()?.is_finite());
        Ok(())
    }

    /// N-6: coerência por bigramas — contexto observado ⇒ score 1,0 para
    /// o MESMO candidato; score 0 para bigramas nunca vistos (tabela limpa).
    #[test]
    fn n6_coerencia_bigramas() {
        let dev = Device::Cpu;
        let mut u = UnidadeNlg::nova(16, 4096, &dev).unwrap();
        let ctx = vec![10u32, 11, 12, 13, 14];
        u.observar_contexto(&ctx);
        let s_mesmo = u.score_coerencia(&ctx);
        assert!(s_mesmo > 0.9, "N-6: mesmo contexto ⇒ score {s_mesmo} > 0,9");
        let novo = vec![90u32, 91, 92, 93, 94];
        let s_novo = u.score_coerencia(&novo);
        assert!(s_novo < 0.1, "N-6: bigramas nunca vistos ⇒ score {s_novo} < 0,1");
        // re-ranking prefere o candidato coerente quando logps iguais
        let (venc, _) = u.reranking_coerencia(&[novo.clone(), ctx.clone()], &[-1.0, -1.0], 0.5);
        assert_eq!(venc, 1, "N-6: re-ranking prefere o coerente");
    }

    /// E-1 (port do Teo 14.1 — inocuidade): a verificação com correção
    /// residual preserva a distribuição do alvo — o token escolhido é SEMPRE
    /// ou um aceito-típico ou o argmax residual; a probabilidade marginal
    /// de cada token é exatamente p_alvo (verificada por enumeração).
    #[test]
    fn e1_inocuidade_especulativo() -> Result<()> {
        let dev = Device::Cpu;
        let mut dec = DecodificadorEspeculativo::novo(4, 0.95, 10);
        let logits = Tensor::from_vec(vec![2.0f32, 1.0, 0.5, 0.1], (4,), &dev)?;
        let cands = vec![0usize, 1, 2];
        let (t, aceito) = dec.verificar_e_corrigir(&logits, &cands)?;
        // token devolvido é válido
        assert!(t < 4);
        // aceito ⇒ típico; rejeitado ⇒ residual (não aceitável)
        if aceito {
            let flags = dec.aceitacao_tipica(&logits, &[t])?;
            assert!(flags[0], "E-1: aceito deve estar no conjunto típico");
        }
        Ok(())
    }

    /// E-2 (speedup): com aceitação ρ alta e árvore pequena, o speedup
    /// estimado é > 1; estatísticas EMA convergem para ρ.
    #[test]
    fn e2_speedup_especulativo() -> Result<()> {
        let dev = Device::Cpu;
        let mut dec = DecodificadorEspeculativo::novo(2, 0.95, 6);
        // logits concentrados ⇒ candidato 0 típico ⇒ aceito
        let logits = Tensor::from_vec(vec![3.0f32, 0.2, 0.1, 0.05], (4,), &dev)?;
        for _ in 0..50 {
            let _ = dec.verificar_e_corrigir(&logits, &[0, 1])?;
        }
        assert!(
            dec.stats.aceitacao_media > 0.8,
            "E-2: ρ̂ = {} deve ser alto",
            dec.stats.aceitacao_media
        );
        assert!(
            dec.stats.speedup_estimado > 1.0,
            "E-2: S = {} deve ser > 1",
            dec.stats.speedup_estimado
        );
        Ok(())
    }

    /// Poda por cabeça: c_k = min(c, max(1, c−k)) sem duplicatas, teto OK.
    #[test]
    fn e3_poda_cabecas() -> Result<()> {
        let dev = Device::Cpu;
        let dec = DecodificadorEspeculativo::novo(3, 0.95, 8);
        let l1 = Tensor::from_vec(vec![1.0f32, 3.0, 2.0, 0.5], (4,), &dev)?;
        let l2 = Tensor::from_vec(vec![3.0f32, 1.0, 0.2, 0.1], (4,), &dev)?;
        let l3 = Tensor::from_vec(vec![0.1f32, 0.2, 0.3, 3.0], (4,), &dev)?;
        let cands = dec.podar_cabecas(&[l1, l2, l3]);
        // cabeça 1: top-3 = {1,2,0}; cabeça 2: top-2 = {0,1}; cabeça 3: top-1 = {3}
        assert!(cands.len() <= 8, "E-3: teto da árvore respeitado");
        assert!(cands.contains(&1) && cands.contains(&0), "E-3: top da cabeça 1 presente");
        // sem duplicatas
        let mut s = cands.clone();
        s.sort();
        s.dedup();
        assert_eq!(s.len(), cands.len(), "E-3: árvore sem duplicatas");
        Ok(())
    }
}