Lição 03 · Fundamentos · Alembic × Hermes v3 · @alembic/etl
Alembic × Hermes — O Curso de Fusão v3 · Visual Course

@alembic/etl — o T0 determinístico que nunca gasta

Ao fim desta lição você sabe como o pipeline T0 processa 100% do corpus a US$ 0, por que re-rodar não duplica nada — e por que apontar o distill para a pasta errada produz residue 0 em silêncio (a gotcha que custou caro).

Leia primeiro (fonte primária)
packages/etl/src/pipeline.ts — o T0 inteiro em um arquivo

pipeline.ts (o walk + dedupe + rota), priors.ts (a família pelo 1º segmento) e stores.ts (os livros-razão append-only) são as três peças que esta lição destila.

Você vai conseguir
  • Narrar o caminho de uma linha .jsonl: hash → dedupe → contrato → score ou residue.
  • Explicar por que TODO IO da ETL passa pela porta FsPort.
  • Diagnosticar (e nunca mais causar) a gotcha family-prefix.
  • Apontar onde vivem opportunity-graph.jsonl e learnings.jsonl.
Leia a versão simples, ou abra a camada técnica em qualquer seção.
1

A grande ideia


Antes de qualquer modelo ser pago, o corpus inteiro passa por um funil grátis e determinístico: o pipeline T0. Ele caminha o diretório como stream (nunca carrega a árvore na memória), lê cada .jsonl linha a linha, calcula o SHA-256 de cada linha e consulta um livro-razão: “já vi este conteúdo?”. Duplicata? Pula. Novo? Valida contra o contrato wiki, pontua com o scorer determinístico — e o que merece um tier mais alto vira residue, a fila de trabalho dos tiers pagos (lição 05).

Duas propriedades pagam o aluguel: re-rodar é de graça (corpus inalterado ⇒ só outcomes duplicate, zero deltas — pipeline.ts:260-263) e nada é destruído (fonte é read-only; saídas são append-only). É o chão de fábrica sobre o qual as DUAS cadeias de valor da ADR-0002 operam.

Pense como… a esteira de raio-X do aeroporto: toda mala passa, nenhuma é aberta à força (read-only), as suspeitas ganham etiqueta para inspeção manual (residue) e a câmera grava tudo num log que só cresce (ledger). Onde quebra: no aeroporto a fila anda uma vez; aqui a MESMA mala pode voltar amanhã — e a esteira a reconhece pelo hash e nem gasta energia de novo.

Por baixo do capô

runT0Pipeline (pipeline.ts:265-298) orquestra: walkCorpus (114-140) emite arquivos .jsonl e marcadores de exclusão — Repos/Models e Repos/Prompts nunca são descidos (priors.ts:34-37, pesos de modelo e corpora de prompt não são sinal destilável). Cada linha passa por processLine (177-248): sha256Hex(raw) → índice de dedupe → JSON.parsevalidateLlmWikiContractrecordProcessed no ledger → rota. Os cinco destinos possíveis são o tipo ItemOutcome: scored | residue | blocked | duplicate | invalid (pipeline.ts:44-49).

Gotcha herdada do walk: readDir devolve isDirectory/isFile do dirent real (fs-port.ts:138-149) — um symlink não é nem um nem outro, então walkCorpus não segue symlinks. Família symlinkada = família invisível.

2

Em uma imagem


alembic-completo · 0001-camada-l0-contracts-etl · s0
corpus (read-only) *.jsonl, linha a linha walkCorpus exclui Models/Prompts não segue symlink sha256Hex(raw) já visto no ledger? _alembic-processed contrato wiki validate + score L0 prior da família scored (T0) residue ↑tier _alembic-residue.jsonl blocked · duplicate · invalid tudo determinístico, $0 — nenhum modelo é chamado no T0
Leia da esquerda → direita: uma linha do package.jsonl de um bookmark atravessa a esteira e sai num dos cinco outcomes (pipeline.ts:44-49). Residue = fila dos tiers pagos.
3

FsPort — o IO como porta injetada


A ETL nunca importa node:fs diretamente nas funções de negócio: todas recebem um FsPort — a porta de efeito colateral. Isso compra duas coisas: testabilidade (um fake em memória roda a suíte hermética) e auditabilidade (a superfície de efeito é uma interface de 8 métodos, não chamadas espalhadas).

Método (fs-port.ts:61-83)PapelInvariante
stat / readDir / openLineStreamler o corpusread-only; ENOENT não lança (61-65, 121-123)
appendLineescrever ledger/residueappend-only — só adiciona linha (72-73)
ensureDircriar o output diruma vez, recursivo
writeFileAtomictrocar um arquivo inteirotemp + rename: ninguém vê arquivo pela metade (74-80, 172-176)
readText / joinPathutilitáriosarquivos pequenos; separador da plataforma
Regra do repo (CLAUDE.md): “use FsPort (@alembic/etl) for testable IO; avoid direct fs in packages other than ETL/CLI.” A porta nasceu aqui e virou o padrão de IO de todo o motor — inclusive dos subsistemas portados do Hermes (lição 06 em diante).
4

Famílias, priors — e a gotcha family-prefix


Antes de gastar um centavo, cada arquivo ganha um prior: uma aposta barata de “quão promissor é isso?” baseada apenas na família da fonte. E a família vem de UM lugar: o primeiro segmento do caminho relativo ao corpus (classifyFamily, priors.ts:70-76). Transcripts/… mira T2 com prioridade 0.9; Bookmarks/… mira T1; segmento desconhecido vira Unknown → T0 (priors.ts:54-64).

E aqui mora a pegadinha mais cara do motor: routesToResidue só emite residue quando o tier do prior é estritamente maior que o piso T0 (priors.ts:99-102). Unknown mira T0 ⇒ nunca vira residue ⇒ os tiers pagos nunca veem o item. Sem erro, sem aviso: residue 0, silencioso.

✓ CERTO · corpus = o PAI (…/Resources) alembic distill ~/…/Resources rel: Bookmarks/items/…/pkg.jsonl 1º segmento: Bookmarks prior: T1 · prioridade 0.5 T1 > T0 ⇒ RESIDUE ✓ os tiers pagos recebem a fila ✗ ERRADO · corpus = a RAIZ da família (…/Resources/Bookmarks) alembic distill ~/…/Bookmarks rel: items/…/pkg.jsonl 1º segmento: “items” não é família ⇒ Unknown (T0) T0 = T0 ⇒ residue 0 SILENCIOSO: sem erro, sem aviso O caminho relativo perde o prefixo da família quando você aponta DENTRO dela. Escopo de UMA família sem perder o prefixo: recriar só os package.jsonl num pai temporário (truque cpio).
A gotcha family-prefix: mesma pasta, mesmo conteúdo — mas o corpus errado apaga o 1º segmento e Unknown→T0 engole tudo (priors.ts:70-76 + 99-102).
Custo real da gotcha
Foi exatamente assim que a rota de learnings ficou “rodando” sem produzir nada: apontada na raiz da família ⇒ residue 0 ⇒ cadeia paga sem entrada. Corrigido o alvo para o PAI de Resources (+ PR #157 no leilão T2), o primeiro run real nasceu: 1.022 learnings de Bookmarks (lição 05).
Você roda alembic distill ~/Documents/Resources/Bookmarks --offline (a raiz da própria família). O que acontece?
Resposta: residue 0, silencioso. O caminho relativo vira items/…, o 1º segmento não casa com nenhuma família (priors.ts:70-76) ⇒ Unknown com prior T0 ⇒ routesToResidue exige tier estritamente > T0 (priors.ts:99-102) ⇒ nada sobe. O comando termina “com sucesso”. Aponte sempre no PAI de Resources.
5

Stores append-only — os livros-razão


O que o funil produz precisa durar. A ETL define dois stores content-addressed sob o data dir — e as quatro garantias deles (stores.ts:26-34): append-only (linha nunca é reescrita), content-addressed (cada registro carrega o SHA-256 do seu JSON canônico; conteúdo idêntico = no-op), atômico (via writeFileAtomic) e validado (Zod antes de persistir — registro malformado é impossível de gravar).

produto · 0003-capabilities-atomicas · s2
BUSINESS (cadeia de sinais)

Business/opportunity-graph.jsonl (stores.ts:40) — edges tipados + sinais minerados. Hoje: 2.589 signals.

LEARNING (cadeia de aprendizados)

Skills/learning/learnings.jsonl (stores.ts:43) — os learnings da ADR-0002. Primeira safra real: 1.022.

O detalhe esperto: o hash é calculado sobre canonicalJson — chaves ordenadas recursivamente (stores.ts:83-93) — então dois registros estruturalmente iguais colapsam no mesmo hash mesmo que a ordem das chaves difira. E o hash do envelope é recomputado na leitura, nunca confiado às cegas (stores.ts:70-77).
6

No código


A função que decide TUDO sobre famílias tem sete linhas. Leia com atenção o split — é ele que a gotcha explora:

packages/etl/src/priors.ts:70-80 (trecho real, não editado)
export const classifyFamily = (relativePath: string): SourceFamily => {
  const head = relativePath.split(/[\\/]/).find((seg) => seg.length > 0) ?? '';
  const match = SOURCE_FAMILIES.find(
    (family) => family !== 'Unknown' && family === head,
  );
  return match ?? 'Unknown';
};

/** The SignalPrior for a corpus-relative path. Pure. */
export const priorFor = (relativePath: string): SignalPrior =>
  FAMILY_PRIORS[classifyFamily(relativePath)];

Acesse você mesmo

As nove famílias reconhecidas estão em priors.ts:15-25: Bookmarks, Transcripts, Repos, Circle, Discord, Skool, Whatsapp, Skills — e Unknown como fallback. Os priors por família (tier + prioridade 0–1) em priors.ts:54-64. Compare com o consumo em pipeline.ts:282 (const prior = priorFor(entry.relativePath)): o caminho é SEMPRE relativo ao corpusDir que você passou na CLI — por isso o alvo importa tanto.

Camada técnica — comandos desta seção
# o run T0 hermético, $0 (aponte no PAI de Resources!)
alembic distill ~/Documents/Resources --offline

# prova do dedupe: rode duas vezes e compare — a 2ª só conta duplicates
alembic distill ~/Documents/Resources --offline   # de novo

# inspecione os artefatos append-only do T0 (no data dir do run)
wc -l _alembic-processed.jsonl _alembic-residue.jsonl
head -1 Business/opportunity-graph.jsonl | head -c 300

# a cadeia típica de uma família wiki
alembic wiki-bridge ~/Documents/Resources/Bookmarks --dry-run
alembic distill ~/Documents/Resources --offline
7

Experimente


Fixe os quatro reflexos do operador de ETL.

dedupe
Por que re-rodar o T0 não duplica nada?
clique para virar
Cada linha é hasheada (sha256Hex, dedupe.ts:38) e conferida no ledger _alembic-processed.jsonl — hash visto ⇒ outcome duplicate, zero escrita (dedupe.ts:90-112).
gotcha
Residue 0 e nenhum erro. Primeira suspeita?
clique para virar
Family-prefix: você apontou DENTRO da família. O 1º segmento relativo virou “items” ⇒ Unknown ⇒ T0 ⇒ sem residue. Aponte no pai de Resources.
walk
O walkCorpus segue symlinks?
clique para virar
Não. O dirent de um symlink não é isDirectory nem isFile (fs-port.ts:138-149), então o walk o ignora — uma família symlinkada fica invisível ao T0.
stores
O que impede gravar um registro malformado?
clique para virar
appendStoreRecord valida com o schema Zod ANTES de escrever (stores.ts:148-166) — parse falhou, nada persiste. E o hash canônico deduplica conteúdo idêntico.
Desafio mental: “se eu movesse Transcripts/ para dentro de Bookmarks/, o que mudaria nos priors?” (resposta: tudo viraria Bookmarks/T1 — o 1º segmento manda). Teaser da lição 04: o residue que o T0 empilhou precisa de um modelo para virar sinal — e TODA chamada de modelo do motor passa por uma única porta: o ModelAdapter, a cintura estreita.