O funil: um ETL de 4 camadas com piso de $0
O funil é como o Alembic transforma um corpus de fontes brutas em sinais de negócio validados e learnings — a um custo que começa em exatamente $0 e só sobe à medida que candidatos provam valer o gasto. É uma cascata de quatro camadas: T0 determinística (grátis) → T1 local (~grátis) → T2 shortlist de fronteira medida → T3 council + painel verificador. Só um GO verificado emite. É o motor de dados do qual o ciclo de aprendizado se alimenta.
packages/harness/src/funnel.ts + packages/etl/src/*
Esta lição destila o orquestrador do funil e os módulos de ETL que ele compõe, lidos literalmente do repositório (rodapé). Por que importa pra missão: é o coração econômico do Alembic — a peça que deixa rodar a destilação inteira de um corpus enorme por $0 e só pagar fronteira pelo que sobreviveu a dois filtros mais baratos.
- Explicar por que o funil estreita o gasto e não o corpus — a curva de custo verga na direção certa.
- Nomear o que cada camada faz:
runT0Pipeline→runT1Extraction→runT2Shortlist→runT3Council. - Descrever o GO-verificado como a conjunção de duas travas: consenso
GOeisPanelEmissionApproved. - Defender as três invariantes que o funil nunca pode regredir: PII antes de egress, orçamento fail-closed, append-only.
MIN_VALID_AGENTS)01 · A cascata — estreite o gasto, não o corpus
A camada mais barata toca 100% do corpus; cada camada seguinte é mais cara mas vê apenas os sobreviventes da anterior. Esse é o truque que faz a curva de custo vergar na direção certa: você nunca paga fronteira por um item que um filtro de $0 já podia descartar.
It drives one corpus through four tiers over materialized wiki packages.— packages/harness/src/funnel.ts, docstring do orquestrador (55–81)
O funil como funil — largura = quantos itens, fundo = quanto custa
Desenhe-o literalmente como um funil. A boca larga em cima é barata e vê tudo; o bico estreito embaixo é caro e vê pouco. A largura de cada faixa é quantos itens passam; a etiqueta à direita é o que aquela faixa custa:
A curva de custo verga: barato sobre muitos, caro sobre poucos
Por que isso é barato no agregado? Porque o produto itens × preço-por-item é mínimo em cada faixa. A faixa que vê milhares de itens custa $0 cada; a faixa que custa caro cada vê pouquíssimos. Veja as duas curvas cruzando:
02 · Cada camada, por dentro
As quatro camadas não são abstratas: cada uma é uma função real em funnel.ts (T1–T3) ou em @alembic/etl (T0). Primeiro a visão simples, depois a tabela técnica com os nomes verbatim.
• T0 — "leia tudo de graça e jogue o lixo fora": anda no corpus, remove duplicatas, valida o formato, dá uma nota e separa o que merece atenção (o resíduo).
• T1 — "transforme cada sobrevivente num sinal", usando um modelo local (custo ~zero).
• T2 — "pegue os sinais mais fortes e mande para um modelo de fronteira" (aqui sim você paga, mas só pelos melhores).
• T3 — "submeta a um comitê e a um verificador independente": só o que ambos aprovam vira oportunidade.
funnel.ts): runT0Pipeline (de @alembic/etl, importado) → runT1Extraction → runT2Shortlist → runT3Council. O registry de adapters é injetado (FunnelOptions.adapters): um registry offline torna a run inteira $0 e hermética (sem rede). A saída é o FunnelReport, com contadores por camada + costUsd + verifiedSignals.T0 por dentro — cinco passos determinísticos, custo zero
A camada grátis não é mágica: é uma esteira de cinco passos puros, sem nenhum modelo. É justamente por não chamar LLM que ela pode rodar sobre 100% do corpus a $0. Siga a esteira:
O que é o "score 6-dim" — as seis dimensões de T0
Aquele passo score não dá uma nota única arbitrária: o scorer determinístico de L0 pontua cada pacote em seis eixos, de 0 a 5 cada, somando 0 a 30. É essa nota que o prior usa para decidir se um item vira resíduo (sobe) ou fica no piso. Veja os seis eixos e a barra de corte:
understandingChars, researchRefs, qaPairs, hasRawSource …), não de um modelo. Mesmo corpus → mesmas seis notas → mesmo resíduo. provenance é binário-ish (hasRawSource ? 5 : 1): sem ponteiro de fonte resolvido, o item perde quase todo o eixo. As notas deste gráfico são um exemplo; o que é fato do código é quais são os seis eixos, a faixa 0–5/0–30 e a barra meetsBar em 18.| Camada | Função | O que faz | Custo |
|---|---|---|---|
| T0 | runT0Pipeline | walk → dedupe SHA-256 → valida contrato → score 6-dim → emite resíduo, sobre 100% do corpus (excluindo Repos/Models + Repos/Prompts) | $0 |
| T1 | runT1Extraction | um BusinessSignal por item de resíduo via o adapter LOCAL injetado; free-tier, então na prática nunca bloqueado por orçamento | ~$0 |
| T2 | runT2Shortlist | filtra por strength >= shortlistMinStrength e refina os sinais T1 mais fortes em lotes via um modelo FRONTIER; toda chamada paga é medida | medido |
| T3 | runT3Council | um council sintético de 3 membros (optimist/analyst/pessimist, atende MIN_VALID_AGENTS) + o painel verificador N-lentes | medido |
O fluxo de dados T0 → T1 → T2 → T3
Como o dado muda de forma ao descer? Cada camada recebe a saída tipada da anterior e a estreita. Siga as setas — repare como o tipo some de "arquivos" para "resíduo" para "sinais" para "sinais verificados":
03 · O custo por camada — lado a lado
O ponto inteiro do funil é o custo. Veja-o explícito, camada a camada: quem paga, quem vê quantos itens e o que protege cada chamada paga. A tabela diz os fatos; o gráfico abaixo torna o contraste visível.
| Camada | Itens que vê | Modelo | Custo/chamada | Proteção |
|---|---|---|---|---|
| T0 | 100% do corpus | nenhum (determinístico) | $0 | — |
| T1 | só o resíduo de T0 | LOCAL (free-tier) | ~$0 | BudgetGuard.check (sempre passa em $0) |
| T2 | só os T1 mais fortes | FRONTIER | medido | BudgetGuard.check fail-closed por lote |
| T3 | só a shortlist | FRONTIER ×3 (+ painel) | medido | BudgetGuard.check por membro |
O gráfico empilha as duas dimensões que importam: a cor da etiqueta é quem paga; a largura é a fatia do corpus que cada camada toca. Repare como o que custa é justamente o que estreitou:
04 · Calcule o gasto você mesmo (slider)
Sinta a economia. Arraste o tamanho do corpus: as barras mostram quantos itens cada camada toca, e o painel calcula o gasto agregado. Repare como T0/T1 ficam em $0 e o total só cresce com as poucas chamadas pagas de T2/T3 — e como o offline zera tudo.
[uncertain] as taxas de sobrevivência e o preço por chamada são valores de demonstração (não há constantes fixas no código — dependem de shortlistMinStrength, do corpus e da tabela de preços do registry). O que é fato do código: T0/T1 custam $0/~$0 e --offline zera o total.
--offline injeta um registry de adapters offline; createBudgetGuard trata qualquer chamada projetada em $0 como sempre-aprovada, então nenhuma camada gasta. Por isso alembic distill <corpus> --offline roda o pipeline inteiro, hermético, a custo zero — o piso do design, não um modo degradado.05 · O sinal GO-verificado — duas travas, não uma
Chegar ao T3 não basta para emitir. Um resultado só vira oportunidade quando ambos são verdade: a decisão de consenso é GO e o painel N-lentes aprovou a emissão (verificado, não estacionado). Um GO puro não basta:
// packages/harness/src/funnel.ts:509-517 — o portão de emissão (forma do código) if (!isPanelEmissionApproved(report) || consensus.decision !== 'GO') { return { verified: 0, edges: [], learnings: [], signals: [] }; // nada emite } // caso contrário: emite arestas + learnings; expõe os sinais PII-safe em verifiedSignals
Lido na forma positiva: emite ⟺ consensus.decision === 'GO' && isPanelEmissionApproved(report). Qualquer trava que falhe → o early-return devolve zero e nada chega ao store.
As duas travas como um portão AND
Pense em duas chaves girando a mesma fechadura: a porta só abre se as duas girarem. Uma sozinha não emite nada:
Os BusinessSignal[] GO-verificados são expostos em FunnelReport.verifiedSignals — a ponte PII-safe para a marketing factory (distillAndMarket). A Lição 18 abre o painel por dentro; o ponto aqui é que o funil exige consenso e verificação independente antes de gastar esforço a jusante ou emitir qualquer coisa para fora.
Dentro da trava 1 — três votos, e o quórum que tolera faltas
Como o consenso vira GO? O runT3Council despacha um council sintético fixo de três membros — optimist, analyst, pessimist — contra o adapter de fronteira de T3, com budget-gate em cada chamada. Aqui mora um detalhe fail-closed elegante: se o orçamento bloqueia um membro (ou o parse do voto falha), aquele voto é simplesmente omitido — a run não quebra. Quem decide se ainda há consenso é o quórum MIN_VALID_AGENTS = 3:
GO, mas o painel N-lentes estacionou uma lente. Quantos sinais o funil emite para verifiedSignals neste caso?
isPanelEmissionApproved(report) === false dispara o early-return { verified: 0, …, signals: [] }. Não importa que o consenso tenha sido GO — uma trava falha derruba a emissão inteira. Se você chutou "emite com flag de aviso", caiu na intuição errada: o funil não emite-com-ressalva, ele não emite. Consenso e verificação são travas independentes, e a porta só abre com as duas.06 · Fluxograma — "esse sinal vai emitir?"
Junte tudo numa única decisão. Diante de um item do corpus, siga as setas: cada losango é uma pergunta que escolhe o caminho, do $0 de T0 até o portão final. Os caminhos de descarte (PII, orçamento, painel) são tão importantes quanto o caminho de emissão.
extractionInput redige o conteúdo de canal privado antes da chamada de modelo, para que PII nunca chegue a um adapter (possivelmente em rede) intacta. A emissão é re-checada por assertRedactedForEmit — duas barreiras, não uma.budget.check negativo apenas pula a chamada paga (T1 conta como budgetBlocked; T2/T3 mantêm o sinal sem refino). A run continua e termina — o orçamento é um teto, não uma exceção que mata o processo.07 · Três invariantes que o funil nunca pode regredir
O docstring do funnel.ts chama três invariantes de "load-bearing, do not regress". São as garantias que tornam o funil seguro o suficiente para rodar sozinho sobre um corpus privado.
Um sinal de um canal PRIVADO (whatsapp, discord, skool, circle — a constante PRIVATE_CHANNELS) é redigido antes da chamada de modelo (extractionInput chama redactPii) e protegido de novo por assertRedactedForEmit antes de qualquer escrita (emitSafeSignal). Um sinal de canal privado não-redigido é descartado, nunca emitido. FunnelReport.t1PiiBlocked deveria ser sempre 0 — um valor não-zero é um alarme fail-closed. Governado pela ADR-0011.
Toda chamada paga (T2/T3, e a verificação de T1) passa por um BudgetGuard.check fail-closed antes do despacho; uma violação projetada bloqueia a chamada e a camada degrada em vez de estourar o gasto. A precificação sempre usa a tarifa de tier do registry (TierRoute.pricingModelId), nunca um override de catálogo — então um modelo de gateway sobrescrito que não está no registry ainda é medido contra o teto pela tarifa do seu tier. Você não consegue contornar o orçamento por acidente.
Resultados fluem para os dois stores via escritas append-only, content-addressed, atômicas, validadas por schema; leituras da fonte permanecem read-only. As duas saídas são o grafo de oportunidades BUSINESS (Business/opportunity-graph.jsonl) e o store de LEARNINGS (Skills/learning/learnings.jsonl) — as duas cadeias de valor da ADR-0002.
A barreira de PII em duas etapas
A invariante ① é a mais sutil: a redação não acontece uma vez, mas duas. Veja o caminho de um sinal de WhatsApp passando pelas duas barreiras — e o que acontece se ele chegar cru à segunda:
Append-only: quatro garantias numa escrita
A invariante ③ empacota quatro propriedades numa só escrita. Cada uma fecha uma classe de bug — corrupção, duplicata, leitura parcial, registro malformado:
PRIVATE_CHANNELS (whatsapp). Logo, sujeito a redação forçada.extractionInput roda redactPii(item.evidence) antes de montar a entrada do modelo. No caminho normal, o modelo nunca vê PII.emitSafeSignal chama assertRedactedForEmit(signal, channel).err (nunca lança), emitSafeSignal retorna undefined → o sinal é filtrado fora de verifiedSignals, e t1PiiBlocked incrementa como alarme.assertRedactedForEmit? Pense antes de revelar.
emitSafeSignal chama o gate também para canais públicos (ramo !isPrivateChannel), só que sem forçar redação antes. O gate ainda valida; a diferença é que conteúdo público é enviado verbatim ao modelo na barreira 1. Lição: a segunda barreira é universal; a primeira (redação forçada) é só para canais privados.08 · Por que o funil vive no harness L4, e não no etl
Uma decisão de arquitetura fecha a lição. O funil orquestra três camadas: adapters L1 + council L2 + etl L0. Onde, então, ele deve morar? A resposta vem de uma regra de dependência, não de gosto.
Colocá-lo em etl forçaria o etl a depender para cima de adapters/council, invertendo o grafo de camadas. Então a T0 determinística fica em @alembic/etl (puro, só-contracts) e o orquestrador que chama T0→T3 vive em @alembic/harness:
etl), orquestrador no topo (harness). Quem inverte a seta quebra o grafo — e o build de camadas o pega.09 · O que o funil te devolve — anatomia do FunnelReport
Uma run inteira do funil produz um objeto: o FunnelReport. Ele é a prova auditável do que aconteceu em cada camada — não só o que saiu, mas onde as coisas pararam e quanto custou. Ler este contrato é ler o funil de relance. Os campos não são decorativos: três deles são alarmes de invariante.
t0.residue > t1Extracted > t2Shortlisted > t3Verified. Se algum degrau não estreita, ou o corpus é minúsculo, ou um filtro não está discriminando. A forma do report é a forma do funil.costUsd mora no report (e não num log à parte): custo é um resultado de primeira classe da run, não um efeito colateral. Ele soma toda chamada paga (T2/T3 + a verificação de T1) medida pela tarifa de tier — então o número que você lê é o número que o BudgetGuard protegeu. Auditável de ponta a ponta.10 · Como isso se encaixa
O funil não é uma peça isolada — é o motor de dados que fica entre as fontes brutas e o ciclo de aprendizado. Olhe a peça inteira: fontes brutas entram por ingest/wiki packages, descem os quatro tiers T0→T3 (esta lição), e só um GO-verificado sai pelo bico para virar learnings que realimentam o loop. As outras peças da metodologia ou alimentam o funil ou consomem o que ele emite.
verifiedSignals; o loop de aprendizado lê os learnings). Ele reusa peças que outras lições abrem por dentro: o council/painel de T3 é o mesmo gate do runtime de execução. Veja o todo no mapa interativo da metodologia.As peças que se conectam a esta
O funil toca cinco outras lições — cada uma abre uma peça que aqui aparece de relance:
Porque conecta: as três invariantes que o funil "nunca pode regredir" (PII antes de egress, orçamento fail-closed, append-only) são a aplicação concreta das invariantes do sistema — a Lição 16 generaliza o que aqui você viu fechar o portão.
Porque conecta: a trava 1 do GO-verificado (o council de 3 membros) e a trava 2 (o painel N-lentes /
isPanelEmissionApproved) são exatamente a peça que a Lição 18 abre por dentro — T3 é onde o funil chama esse council.Porque conecta: a barreira de PII em duas etapas (
redactPii antes do modelo + assertRedactedForEmit antes da escrita) é a regra de segurança que a Lição 26 trata em profundidade — aqui ela é a invariante ① do funil.Porque conecta: os tiers T0–T3 e o
BudgetGuard fail-closed que mede cada chamada paga pela tarifa de tier do registry são o mesmo modelo de custo que a Lição 27 detalha — o funil é a aplicação econômica desse roteamento.Porque conecta: o portão AND do funil (GO ∧ painel-aprovado) é o mesmo padrão fail-closed dos gates de execução (Scope → Council → Proof → Validator) — a Lição 17 mostra esse padrão no caminho de build, este aqui mostra no caminho de destilação.
11 · Na prática
Chega de teoria — rode o funil. A camada local é o piso do design, então a forma canônica de exercitá-lo é offline: o pipeline inteiro, hermético, a custo $0. O comando é alembic distill; alembic status mostra o estado dos stores que o funil alimenta.
# O funil noturno, offline = $0 hermético (sem rede, determinístico) # <corpus> = uma família wiki, um pai multi-família, ou um arquivo de records $ alembic distill ~/Documents/Resources --offline # saída esperada (forma ilustrativa — os números dependem do seu corpus): # T0 files=12480 scored=12480 residue=3120 cost=$0 # T1 extracted=3120 piiBlocked=0 budgetBlocked=0 cost=$0 # T2 shortlisted=312 budgetBlocked=0 cost=$0 (offline) # T3 verified=28 opportunities=28 learnings=28 cost=$0 (offline) # total costUsd=$0.00 # Depois: veja o estado dos stores que o funil alimenta $ alembic status
t1PiiBlocked deve ser 0 (não-zero = invariante ① violada); budgetBlocked alto = teto baixo demais; e a contagem deve estreitar degrau a degrau (residue > extracted > shortlisted > verified). Em --offline todo cost é $0 por construção. [uncertain] os números acima são ilustrativos — a forma exata da saída depende da build do CLI; o que é fato é o comando e que --offline zera o custo.cd /Users/acf/Documents/Projects/appfy/alembic e então pnpm -r build — o CLI alembic precisa estar compilado.~/Documents/Resources) ou qualquer pasta com pacotes materializados. <corpus> aceita o mesmo caminho que distill documenta: família, pai multi-família, ou um arquivo de records.alembic distill <corpus> --offline. Como é offline, é hermético e $0 — pode rodar sem o gateway cliproxyapi e sem token.costUsd é $0. Esse objeto é o FunnelReport da seção 09 — a forma do report é a forma do funil.alembic status mostra o estado das duas saídas append-only (oportunidades + learnings) que o funil alimenta.costUsd se tirar o --offline (e o gateway estiver disponível)? Pense antes de revelar.
$0/~$0. O total sobe, mas é medido pela tarifa de tier do registry e protegido pelo BudgetGuard fail-closed: o número final é o número que o guard deixou passar. Lição: tirar o offline não "liga tudo" — liga só as poucas chamadas de fronteira que sobreviveram aos filtros de $0.alembic ingest <source> materializa novas fontes antes do funil; alembic distill <corpus> --offline roda os quatro tiers; alembic status inspeciona os stores depois. Para a teoria de custo por trás do --offline, volte à seção 04 (o slider) e à Lição 27.Confusões comuns
$0 — as camadas locais são o piso do design, não um fallback. alembic distill <corpus> --offline roda o pipeline inteiro com um registry de adapters offline e nunca toca uma API paga.pricingModelId), então até um modelo de gateway sobrescrito no catálogo é medido pelo seu tier. O budget guard vê o custo projetado independentemente de qual nome de modelo concreto foi fixado.NO_GO, um painel estacionado, ou um votes.length === 0 (todos os membros bloqueados por orçamento) devolvem verified: 0 e nada emite.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
runT0Pipeline → runT1Extraction → runT2Shortlist → runT3Council.decision === 'GO' E isPanelEmissionApproved(report). Um AND: qualquer falha → verified: 0.t1PiiBlocked=0) · ② orçamento fail-closed (pricingModelId) · ③ append-only (ADR-0002).Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
strength ≥ shortlistMinStrength e refina só os mais fortes em lotes. a confunde T0 (que de fato vê tudo, de graça) com as camadas pagas — o funil estreita o gasto, não deixa toda camada ver tudo. c troca o quórum do council (3 membros) pela contagem de itens de T2 — coisas diferentes. d é falso: T2 roda sempre que há sinais fortes e orçamento; só é pulada se nenhum sinal atinge o limite ou o budget bloqueia.GO mas o painel N-lentes estacionou uma lente. O funil emite o sinal?signals: []. a trata GO como suficiente, ignorando a segunda trava (o painel) — exatamente o erro que o portão AND existe para impedir. b inventa um "emite-com-aviso" que o código não tem: ou emite limpo, ou não emite. c mistura camadas: orçamento decide se a chamada roda, não se um GO-mais-painel-estacionado emite.emitSafeSignal retorna undefined e o contador sinaliza. b contradiz a invariante ①: o gate é fail-closed, não best-effort. c descreve uma "redação na escrita" que não acontece para o sinal já cru — a barreira 2 valida e descarta, ela não conserta silenciosamente um sinal de canal privado que escapou da barreira 1. d erra o modo de falha: assertRedactedForEmit devolve um err tipado (nunca lança), então a run continua — fail-closed, não crash.