Curso · produto
Alembic — O funil de destilação
O funil de destilação
Esta lição abre o funil: a sequência wiki-bridge → distill → embed-index → vision-index → status que pega um corpus bruto (transcripts, bookmarks, repos, chunks de família-wiki) e destila em duas saídas operacionais — LEARNINGS (o que aprendemos, citável) e BUSINESS SIGNALS (o que poderia virar produto). Tudo offline / $0 por padrão: o adapter local é determinístico e a rede só entra com --online explícito. Esse é o motor que mantém a holding multi-venture alimentada (revisita overview para a foto inteira; quem consome essas saídas vive em capabilities).
O pipeline T0 → T3
O funil é tieriado em quatro estágios. Cada um só roda sobre o que o anterior deixou subir — esse é o ponto: baratear no T0, gastar no T3 só no que sobreviveu. A definição operacional vive em packages/harness/src/funnel.ts (orquestrador) + packages/etl/src/pipeline.ts (o substrato T0).
T0 — substrato determinístico, $0
runT0Pipeline (em packages/etl/src/pipeline.ts) caminha o corpus inteiro como um stream assíncrono — nunca carrega a árvore na memória. Para cada arquivo .jsonl ele aplica isExcluded (que pula Repos/Models e Repos/Prompts, entre outros), lê linha a linha, dedupa por SHA-256 contra o ledger append-only de processados, valida cada linha contra o wiki contract (validateLlmWikiContract), pontua os pacotes válidos com o scorer L0 determinístico, e emite resíduo para um stream append-only — todo item cujo prior aponta para um tier acima do floor, ou que foi marcado blocked, vai para esse resíduo (_alembic-residue.jsonl) para o tier seguinte pegar.
Os priors por família vivem em packages/etl/src/priors.ts: Transcripts/Discord/Circle/Skool → T2, Whatsapp/Bookmarks/Skills/Repos → T1, Unknown → T0. Cada família tem também uma priority usada para ranking. Esses números são doutrina — alterá-los muda o que sobe pela funil.
T1 — extração local, free-tier
O runT1Extraction coleta o resíduo do T0 e dispara um extract por item via o adapter LOCAL do tier T1 (configurado no AdapterRegistry injetado). O prompt pede um único objeto JSON estrito com {kind, summary, evidenceQuote, strength, confidence}. Como o T1 é free-tier por design, o BudgetGuard normalmente sempre clareia — mas a checagem fica lá, fail-closed: se algum dia trocarem T1 por um adapter pago, um cap orçamentário é honrado antes da chamada.
T2 — frontier shortlist com budget
O runT2Shortlist filtra os sinais por strength >= shortlistMinStrength (default 3), agrupa em batches (default 8) e manda cada batch ao adapter frontier do tier T2. Antes de toda chamada paga, o BudgetGuard.check projeta o custo com base no usage estimate (~4 caracteres por token); se o cap fosse estourado, o batch é contado como t2BudgetBlocked e a tier degrada em vez de gastar.
T3 — council debate + verifier
O runT3Council monta um ContextPack com o shortlist, dispara três panelistas sintéticos (optimist / analyst / pessimist) contra o adapter T3, agrega os votos via aggregateConsensus e roda o verifyPanel (P5.5 emission gate: lentes coherence / faithfulness / domain, agregadas por quorum com veto duro). Só quando isPanelEmissionApproved && consensus.decision === 'GO' ele emite: opportunity edges + learnings entram nos dois stores append-only via appendOpportunityRecord / appendLearning (packages/etl/src/stores.ts).
- PII: todo signal de um private channel (whatsapp, discord, slack…) passa por
redactSignale depois peloassertRedactedForEmitantes de poder ser escrito. Uma falha do gate derruba o signal (fail-closed). O contadort1PiiBlocked > 0no relatório é alarme: alguém burlou a redação. - Budget: toda chamada paga (T2/T3) é checada contra o
BudgetGuard; uma projeção de breach bloqueia a chamada e a tier degrada — nunca gasta. - Append-only: todo write nos stores é validado por schema, com content-addressed dedupe. Re-rodar o mesmo corpus converge (re-emitir conteúdo idêntico vira no-op).
As duas saídas: LEARNINGS + BUSINESS SIGNALS
O funil sempre escreve em dois stores append-only sob o --data-dir (default ./data):
Business/opportunity-graph.jsonl— recebe duas espécies de registro discriminadas porkind: signals (o sinal PII-safe direto do shortlist) e edges (relaçõesderives-fromligando o signal à fonte). Construtores:asSignalRecord/asEdgeRecord. Schema:opportunityRecordSchema(z.discriminatedUnion).Skills/learning/learnings.jsonl— recebe learnings capturando o veredicto do council sobre cada pack: statement, confidence, e o array desourceRefsque originou o GO.
Esse é o ponto onde o motor "entrega" para o resto do sistema: o marketing factory consome FunnelReport.verifiedSignals; o AI-Employee consulta os learnings via sua memória semantic; o status apenas conta as linhas dos dois arquivos.
Os comandos, em ordem prática
Use esta cadeia quando tiver um corpus em camadas dir-per-package (a wiki). Tudo offline, $0:
$ alembic wiki-bridge ~/Documents/Resources/Bookmarks
wiki-bridge: Bookmarks (/Users/acf/Documents/Resources/Bookmarks)
total: 1842, written: 1842, skipped: 0, excluded: 11, blocked: 0, errors: 0
wiki-bridge usa walkFamily de @alembic/ingestion (que a camada-CLI alimenta com o isExcluded de @alembic/etl, mantendo a ingestão sem doutrina). Para cada pacote items/<cat>/<id>/ ele escreve uma linha em package.jsonl com o canonical path e o evidence. --family pinota; sem ele, a família é derivada do basename da raiz.
$ alembic distill ~/Documents/Resources --offline
distill: ~/Documents/Resources
T0: 4128 scored, 1842 residue, 23 excluded
T1: 1842 extracted, 0 pii-blocked
T2: 412 shortlisted, 0 budget-blocked
T3: 17 verified
emitted: 34 opportunity, 17 learnings
cost: $0
distill chama runFunnel com um AdapterRegistry hermético (--offline instancia um registry que devolve só adapters $0). O preflight de provider só roda quando não é offline; em offline puro, $0 é garantido.
$ alembic embed-index ~/Documents/Resources/Bookmarks
embed-index: Bookmarks (/Users/acf/Documents/Resources/Bookmarks)
packages: 1842, chunks: 18347, rows written: 18347, errors: 0
index: /Users/acf/.alembic/data/embeddings-index/bookmarks.jsonl
Para cada pacote items/<cat>/<id>/, embed-index lê chunks.jsonl via readWikiPackageDir, embeda cada chunk com buildChunkIndex (backend offline determinístico, default model: 'offline', vetor derivado do text — sem clock nem RNG), e faz append em <data-dir>/embeddings-index/<family>.jsonl com dedupe por chunk_id. Re-rodar acrescenta zero linhas — é byte-stable.
$ alembic vision-index ~/Documents/Resources/Bookmarks
vision-index: Bookmarks (offline)
packages: 1842, images: 612, described: 612, rows written: 612, errors: 0
index: /Users/acf/.alembic/data/vision-index/bookmarks.jsonl
vision-index lê o media_manifest.json de cada pacote (campo images[].local_path), passa cada imagem por describeImages de @alembic/vision e faz append em <data-dir>/vision-index/<family>.jsonl — dedupe por imagem. Offline determinístico por padrão; --online liga a MLX-VLM local (instruções em docs/vision-setup.md; o default validado é Qwen3-VL-30B-A3B). Nunca escreve dentro do corpus.
$ alembic status
status: default tier T4
phases: discover -> validate -> design -> plan -> build -> review -> ship
stores: 4 opportunity, 0 learnings
status apenas conta as linhas dos dois stores append-only e devolve o default tier + as factory phases. Stores ausentes lêem como zero. É a sonda mais barata: se o número subir entre runs, o funil entregou algo novo.
Como funciona por dentro: o caminho de uma linha do corpus
Considere uma linha JSON dentro de Bookmarks/items/agents/some-id/package.jsonl:
- walk —
walkCorpusempackages/etl/src/pipeline.tsdescobre o arquivo (não desce nos excluídos). - dedupe —
sha256Hex(raw); se já está noDedupeIndex, viraduplicatee o processamento para. - validate —
validateLlmWikiContract(parsedJson)devolve{ ok: true, status: 'valid' | 'blocked', value: … }. Inválido virainvalid; valid prossegue. - route —
priorFor(relativePath)diz o tier-alvo da família (Bookmarks → T1). Se o alvo for acima dotierFloor(default T0),emitResidueescreve uma linha em_alembic-residue.jsonle o item sobe. - extract (T1) —
runT1Extractionmonta oModelRunInput; se for canal privado,redactPiiroda antes do prompt; o adapter responde JSON;signalFromResultparseia e produz umBusinessSignaltipado. - PII gate —
emitSafeSignalredaz o signal e rodaassertRedactedForEmit. Falhou? O signal cai. Em canal público, passa direto. - shortlist (T2) — se
strength >= 3, entra no shortlist e o batch é refinado pelo frontier (comBudgetGuard.checkantes de cada chamada). - emit signals —
emitSignalsfazappendOpportunityRecord(asSignalRecord(safe))para cada signal PII-safe do shortlist. - council (T3) — três panelistas,
aggregateConsensus,verifyPanel. Aprovado?asEdgeRecord+learningFor. Reprovado? Zero emissão (mas o shortlist signal já foi para a opportunity-graph no passo anterior). - store —
appendOpportunityRecord/appendLearningescrevem nos dois JSONL append-only com dedupe content-addressed.
Cada passo é função pura (com FsPort injetado nos pontos de IO). Trocar o adapter, o budget cap ou o registry é configuração — não mexe no orquestrador.
Por que esta arquitetura
Três decisões definem o funil:
- Tier antes de modelo — o routing é por tier (
T0..T3, T4, LOCAL), não por nome de modelo.adapterForTierescolhe o adapter via o registry filtrado pelo que está realmente injetado (routableRegistry), e um catalog override permite trocar o wire model sem mudar a tier ou o pricing — o cap orçamentário sempre é cobrado pela tier rate do registry, mesmo se o gateway estiver respondendo com outro modelo. - Offline-default — o adapter local de cada tier é $0 e determinístico, então todo o funil pode rodar sem rede. Isso é o que torna o CI hermético, o desenvolvimento local sem custo, e o
doctorútil em modo isolado. - Append-only + content-addressed — re-rodar não duplica, e o ledger de SHA-256 garante que linhas já vistas viram
duplicatesem custo. Isso transforma o funil de "ETL batch" em "stream incremental contínuo" — o que dá sentido ao ciclowiki-bridge → distill → embed-indexrodando periodicamente sobre o mesmo~/Documents/Resources.
Quiz rápido
1. O --offline torna o distill seguro contra estouro de orçamento. Por quê?
Porque --offline instancia um AdapterRegistry em que cada tier resolve para um adapter local $0. O BudgetGuard ainda roda em cada tier (e ainda checaria), mas como o costUsd é zero, a projeção nunca aproxima do cap. Bônus: o preflight de provider é pulado, então não há rede mesmo se a ALEMBIC_CLIPROXY_TOKEN não existir.
2. Por que o wiki-bridge recebe isExcluded como argumento em vez de importar?
Para manter @alembic/ingestion sem doutrina — o pacote só sabe caminhar a família e escrever o bridge. A política do que é excluído (Repos/Models, Repos/Prompts etc.) vive em @alembic/etl/priors.ts. A camada-CLI (apps/cli/src/commands.ts) é quem casa os dois, injetando isExcluded na chamada de walkFamily. Inversão de dependência: ingestão depende de uma função que a CLI fornece.
3. status diz 0 learnings mesmo depois de um distill "bem-sucedido". Por que pode acontecer?
Porque learning só é escrita quando o council T3 aprova um signal — passa pelo aggregateConsensus com decision === 'GO' e pelo verifyPanel (lentes coherence/faithfulness/domain). Se o shortlist do T2 chegou pequeno, ou se o panel rejeitou, signals podem ser emitidos para a opportunity-graph sem nenhum learning correspondente. status reflete só o que está nos JSONL — é honesto, não cosmético.
Próximas paradas: capabilities (os tijolos $0 — embed, ocr, triage, notes, context-pack, memory — que o funil usa por dentro) e swarm-harness (o que o motor faz com os signals depois de emitidos, dentro de uma mission).