Lab: o passe de aprendizado
No Lab 1 você construiu um store. Agora você liga a pedra angular da fusão (Lições 4 e 8): um passe fechado de auto-aperfeiçoamento onde um revisor propõe gravações duráveis, um gate dispõe, e gravações aprovadas sedimentam no store. Você vai montar reviewAndLearn a partir de três portas injetadas — um proposer fake, um ReviewGate e o MemoryStore real da família do Lab 1 — e ver um turno se dividir nos baldes applied / rejected / failed. Esta é a API real entregue; você a chama exatamente como a produção faz.
reviewAndLearn (54–70)
Este lab dirige a API real entregue, lida literalmente do repo (rodapé com as linhas exatas). Por que importa pra missão: é o mecanismo pelo qual o Alembic aprende sozinho sem nunca aplicar nada que um Validator não tenha aprovado — o coração de "aprender é com gate, não auto-aplicar" (ADR-0018).
- Montar
reviewAndLearna partir das três portas injetadas (ReviewDeps) — sem construir adapter nem store concreto. - Rodar um passe e ler os três baldes
applied/rejected/failedcom suas razões. - Forçar cada balde de propósito — e explicar por que
failednão érejected. - Distinguir um balde de um
errque aborta o passe inteiro (fail-closed).
01 · Propõe · dispõe · sedimenta — a forma do passe
Antes do código, a forma. Pense num diário de bordo guardado por um editor cauteloso. Ao fim de cada turno, um revisor rabisca o que valeria a pena lembrar (propõe). Um editor lê cada rabisco e decide se entra no diário (dispõe). Só então o que passou é escrito em tinta no diário durável (sedimenta). Nada vira tinta sem passar pelo editor — e essa é exatamente a regra de ADR-0018.
A mesma forma, vista como quem toca o quê: o revisor só escreve em rascunho; o editor é o único portão para a tinta; o diário durável só recebe o que o editor liberou. Essa separação é literal no código — reviewAndLearn chama deps.memory.apply(...) apenas depois de verdict.value.approved ser verdadeiro (review.ts:90-101).
02 · As três portas que você liga juntas
O driver reviewAndLearn(summary, deps) depende somente de portas injetadas — sem adapter concreto, sem construção concreta de store (ADR-0009). Suas ReviewDeps são três campos, e todos os três são obrigatórios (review.ts:30-37):
| Porta | Tipo | Em produção | Neste lab |
|---|---|---|---|
| proposer | ReviewProposer | uma chamada de ModelAdapter, em forma de cintura estreita | um fake retornando propostas fixas |
| gate | ReviewGate | o Validator do @alembic/coda | scoreThresholdGate(0.7) (default entregue) |
| memory | MemoryStore | o store durável persistido em arquivo | um MemoryStore real sobre um FsPort fake |
03 · Passo 1 — construa o store (reuse a família do Lab 1)
// setup
O MemoryStore real do @alembic/hermes precisa de um FsPort. Reusamos o mesmo fake apoiado em Map do Lab 1, depois fazemos load() uma vez para inicializar seu estado vivo.
import { MemoryStore, reviewAndLearn, scoreThresholdGate } from '@alembic/hermes'; import { ok, err, type Result } from '@alembic/contracts'; import type { ReviewProposal, ReviewProposer } from '@alembic/hermes'; const memory = new MemoryStore(makeFakeFs(), '/agent'); // FsPort fake do Lab 1 await memory.load(); // inicializa o estado vivo
load() primeiro? O MemoryStore mantém um estado em memória que espelha o arquivo. load() lê o que já existe (ou inicializa vazio) antes de qualquer apply() — sem ele você gravaria sobre um estado não inicializado. É o mesmo store da Lição 07; aqui ele é só a terceira porta do passe.04 · Passo 2 — escreva um proposer fake
Em produção o proposer envolve uma chamada de modelo e traduz seu ModelRunResult num Result. No lab ele só retorna propostas fixas. Cada proposta é um { target, op, rationale, score } — o score ∈ [0,1] é a própria confiança do revisor, e o gate decide se ela passa do piso (types.ts:62-71).
const fakeProposer: ReviewProposer = async (_summary) => { const proposals: ReviewProposal[] = [ { target: 'memory', op: { action: 'add', content: 'Build runs offline by default' }, rationale: 'observed this run', score: 0.9 }, // forte → deve APLICAR { target: 'memory', op: { action: 'add', content: 'maybe prefer tabs?' }, rationale: 'hunch', score: 0.4 }, // fraco → deve REJEITAR ]; return ok(proposals); };
ok(...) e não só o array? A porta proposer retorna Result<readonly ReviewProposal[], Error>, nunca lança (types.ts:118-120). Uma falha do modelo em produção vira err(...), que faz o passe inteiro falhar fechado. Envolver o array em ok diz "o revisor rodou com sucesso e aqui está sua saída".fakeProposer acima devolve duas propostas: uma com score 0.9 e outra com score 0.4, sob o gate default (piso 0.7). Depois de um passe, quantos itens caem em applied[], quantos em rejected[] e o que sobra em memory.entries('memory')? Chute antes de revelar.
applied = 1, rejected = 1, failed = 0. A 0.9 ≥ 0.7 aprova e sedimenta; a 0.4 < 0.7 é rejeitada antes de tocar o store. Logo memory.entries('memory') tem exatamente ['Build runs offline by default'] — a rejeitada nunca foi gravada. Se você chutou "as duas aplicam", lembre: o piso é a defesa de "aprenda só de vitórias validadas" (gate.ts:24-36).05 · Passo 3 — rode o passe e leia os baldes
// a chamada
Agora monte as três portas e rode um passe. O gate default aprova score ≥ 0.7 (a fronteira é inclusiva, gate.ts:30), então a proposta 0.9 aplica e a 0.4 é rejeitada.
const result = await reviewAndLearn('finished a unit; tests green', { proposer: fakeProposer, gate: scoreThresholdGate(0.7), // o default conservador entregue memory, }); if (result.ok) { console.log(result.value.applied.length); // 1 (a proposta 0.9) console.log(result.value.rejected.length); // 1 (a proposta 0.4) console.log(result.value.failed.length); // 0 console.log(memory.entries('memory')); // ['Build runs offline by default'] }
O store agora contém exatamente a gravação aprovada. A proposta rejeitada carrega a razão do gate — "score 0.4 < threshold 0.7 (learn only from validated wins)" — então nada é descartado em silêncio (gate.ts:32-33).
score === min: o teste entregue "rejects 0.69 and approves 0.70 at the default 0.7 floor" (review.test.ts:182) fixa exatamente isso. Uma proposta de 0.70 exatamente passa; 0.69 não. Detalhe pequeno, consequência grande: a fronteira do que o agente aprende é determinística e testada.06 · Passo 4 — force cada balde, na mão (worked → agora você)
Você viu o passe rodar feliz. Agora force cada um dos três destinos de propósito — é a melhor forma de entender a diferença entre eles. Siga os passos; o último exercício é seu.
Os três baldes não são aleatórios: cada um é o cruzamento de duas decisões binárias — o gate olha o score, e (só se aprovar) o store olha o tamanho. Esta matriz mostra os quatro quadrantes e em que balde cada combinação cai:
applied. Mande uma proposta com score 0.9 e conteúdo curto sob o store normal. 0.9 ≥ 0.7 → o gate aprova; o store grava. Cai em applied[].rejected. Baixe o score para 0.4. 0.4 < 0.7 → o gate recusa e a proposta volta antes de tocar o store. Cai em rejected[] com a razão do gate (review.ts:91-94).failed. Mantenha score 0.95 (o gate aprova) mas encolha o orçamento do store. A gravação estoura o limite de chars; o store devolve err. Cai em failed[] — e o passe continua ok no geral (review.ts:96-100).failed: encolha o memoryCharLimit e mande um conteúdo maior que ele.
const tiny = new MemoryStore(makeFakeFs(), '/agent', { memoryCharLimit: 10 }); await tiny.load(); const r = await reviewAndLearn('turn', { proposer: async () => ok([{ target: 'memory', op: { action: 'add', content: 'a sentence far longer than ten chars' }, rationale: 'x', score: 0.95 }]), // gate APROVA (0.95 ≥ 0.7)… gate: scoreThresholdGate(), memory: tiny, }); // …mas o store rejeita a gravação acima do orçamento: // r.value.applied = [] r.value.rejected = [] r.value.failed = [{proposal, reason:'…would exceed the limit…'}]
score 0.70 exatamente? E com 0.69? Responda antes de revelar.
0.70 → applied (a fronteira é inclusiva: score >= min, gate.ts:30). 0.69 → rejected (fica abaixo do piso). É exatamente o teste "rejects 0.69 and approves 0.70". Dica: o procedimento é sempre o mesmo — o score escolhe entre applied e rejected; o orçamento do store escolhe entre applied e failed.07 · Simulador: force cada balde ao vivo
Agora torne tátil o que você fez na mão. Mexa os três controles — o score da proposta, o tamanho do conteúdo e o orçamento do MemoryStore — e o passe é avaliado exatamente como reviewAndLearn faz: o gate compara score com o piso 0.7; se aprovar, o store compara o tamanho com o orçamento. O balde de destino acende.
score >= 0.7 no gate (gate.ts:30) e a checagem de orçamento no store (memory-store.ts — a gravação "would exceed the limit"). Nenhum número aqui é fabricado; é a mesma decisão que reviewAndLearn toma, só visível.08 · Por que failed não é rejected
A distinção sutil que o simulador deixa óbvia: rejected = o gate disse não; failed = o gate disse sim mas o store não conseguiu gravar (ex.: acima do orçamento de chars). Colapsar um no outro mentiria para o operador.
Repare onde cada desfecho se separa na mesma linha do tempo de uma proposta: rejected sai cedo (no gate); failed só se decide um passo depois (no store); e applied percorre os dois. É a ordem exata de processOne (review.ts:90-101):
Colapsar failed em rejected diria a um operador "a política recusou esta gravação" quando a verdade é "a política aprovou mas o store está cheio". Esses exigem correções diferentes — relaxar o gate vs. consolidar a memória. O teste entregue "records a store-rejected approved write in failed" (review.test.ts:321) fixa exatamente essa separação.
09 · Passo 5 — os caminhos fail-closed
Há um terceiro tipo de desfecho, acima dos baldes: o passe inteiro abortar com err. Duas coisas o causam — um erro do proposer e um erro do gate. Não são baldes: são curto-circuito. Experimente um proposer que retorna err:
const bad = await reviewAndLearn('turn', { proposer: async () => err(new Error('model timed out')), gate: scoreThresholdGate(), memory, }); // bad.ok === false — o passe inteiro falhou fechado; o store fica intocado.
No código, isso é uma linha: if (!proposed.ok) return proposed (review.ts:61) — um erro do proposer faz short-circuit antes de qualquer gate ou gravação. O mesmo vale para o gate: if (!verdict.ok) return verdict (review.ts:90). E um summary vazio ou zero propostas é um no-op válido — ok com três baldes vazios, espelhando o "Nothing to save." da fonte (review.ts:58, 62).
10 · rejected vs failed vs err — lado a lado
Toda a lição gira em torno de três desfechos que parecem "não gravou" mas são fundamentalmente diferentes. Veja-os explícitos, na mesma tabela: o que os causa, onde a proposta pousa, e o que acontece com o resto do passe.
| Desfecho | Causa | Onde pousa | O passe continua? | Linha-fonte |
|---|---|---|---|---|
| applied | gate sim + store sim | applied[] | sim, ok | review.ts:101 |
| rejected | o gate recusou (score baixo) | rejected[] com razão | sim, ok | review.ts:91-94 |
| failed | gate sim, store não (orçamento) | failed[] com razão | sim, ok | review.ts:96-100 |
| err | proposer ou gate retornou err | nenhum balde — aborta | não, err | review.ts:61, 90 |
A coluna que mais separa os quatro desfechos é a última: o passe continua? Três deles são apenas baldes dentro de um ok — o passe segue inteiro. Só o err é diferente em espécie: ele é o Result externo virando { ok: false } e curto-circuitando tudo. Veja a fronteira:
applied/rejected/failed são os três campos de LearnOutcome (types.ts:99-106), sempre dentro de um ok(...). err é o Result externo virar { ok: false }: reviewAndLearn faz return proposed / return verdict quando uma porta falha (review.ts:61, 90), sem nunca lançar. Fail-closed por contrato: o módulo nunca lança através da fronteira (review.ts docstring).11 · Como isso se encaixa
Você acabou de construir reviewAndLearn ligando suas três portas à mão. Afaste a câmera: o que você montou neste lab é o loop fechado em miniatura. As mesmas três portas — proposer → gate → memory — são o último elo da esteira real do motor: a run de hoje só deixa marca durável para a run de amanhã passando por aqui, e só depois que todos os gates passaram. No lab elas são fakes; em produção, o proposer é um ModelAdapter, o gate é o Validador do @alembic/coda e o memory é o store persistido no run-dir. A forma que você ligou não muda — é a invariante de injeção (ADR-0009) que torna o seu passe de brinquedo idêntico, em estrutura, ao de produção.
Onde você está na metodologia. O motor do Alembic é um loop: aprender → analisar → executar uma unidade → verificar na fronteira real → decidir, e então recomeçar com o que se aprendeu. O passe que você reconstruiu neste lab é a peça que torna esse “recomeçar com o que se aprendeu” literal e seguro: sem ele, cada run começaria do zero; com ele — e só com o gate no caminho — a memória cresce apenas com vitórias validadas. Para ver a esteira inteira em movimento e clicar nesta etapa no mapa, abra o mapa interativo da metodologia.
- Lição 08 · reviewAndLearn (a teoria) — é a peça que este lab reconstrói; lá você leu por dentro o passe gated, aqui você o liga das três portas com a própria mão.
- Lição 04 · O loop fechado — é o ciclo que contém este passe; o que você montou é o último elo (“decidir → recomeçar com o aprendido”) que fecha o loop.
- Lição 07 · MemoryStore — é o store que você injetou como terceira porta; o passe não tem dedup próprio, reusa o do store (a seção 10 da Lição 08 mostra “reforçar, não duplicar”).
- Lição 22 · Lab: um subsistema — é o lab irmão; lá você constrói um subsistema do zero pela mesma disciplina de portas injetadas + testes que dirige este passe.
12 · Na prática
Você montou o passe das três portas e forçou cada balde na mão. Agora rode o mesmo passe pela CLI real, ponta a ponta — é o mesmo reviewAndLearn que você acabou de construir, só que dirigido pela alembic run em vez de um teste. O aprendizado é opt-in: uma run normal não aprende. A flag --learn liga o passe gated (é real — apps/cli/src/args.ts:189, default OFF, ADR-0018), e --offline roda tudo hermético ($0, sem rede). Nesse modo o proposer é o adapter offline determinístico e o gate é o Validador — exatamente as portas do lab, agora de verdade:
# liga o passe gated (ADR-0018) ao fim de uma run, hermético e $0 alembic run --goal GOAL.md --plan alembic.plan.ts --learn --offline --yes # … a run executa as unidades e passa pelos gates (Proof + Validador) … # então, SÓ porque --learn está ligado, vem a última linha do passe: # learning gate: 2 applied, 1 rejected
deps.write no fim do Learning Gate em apps/cli/src/commands.ts:1386: imprime literalmente learning gate: ${applied} applied, ${rejected} rejected. Os dois números vêm direto dos baldes do LearnOutcome — os mesmos applied e rejected que você forçou nas seções 05–07. O balde failed não entra nessa linha-resumo: ele fica no log estruturado run:learning-pass-done (com applied/rejected/failed, commands.ts:1379). No offline o resultado é determinístico — os contadores exatos dependem do que o proposer offline gera para o seu GOAL.md; o 2 applied, 1 rejected acima é ilustrativo.Dois detalhes que amarram direto com o que você construiu. Primeiro: sem a flag, nada acontece — o aprendizado é aditivo e desligado por padrão, então uma run verde continua verde (commands.ts:1364, if (args.learn)). Segundo: se não houver adapter de proposer disponível, o passe é pulado (log run:learning-pass-skipped, commands.ts:1367), nunca quebra a run — é a mesma postura fail-closed da seção 09 aplicada ao próprio gatilho.
cd alembic e então pnpm -r typecheck && pnpm -r build && pnpm -w test — o baseline tem que estar verde antes de qualquer run.GOAL.md + alembic.plan.ts. Se ainda não tem um escopo, gere com alembic plan "<seu objetivo>" — ele materializa o GOAL.md, o contrato e o alembic.plan.ts no diretório.alembic run --goal GOAL.md --plan alembic.plan.ts --learn --offline --yes. O --offline dispensa o gateway (sem rede, $0); o --yes auto-aprova o gate do plano (não-interativo); o --learn liga o passe que você montou.learning gate: N applied, N rejected na saída. applied = aprovadas pelo gate e escritas (a coluna verde da sua matriz da seção 06); rejected = o gate disse não (a coluna esquerda). Se aparecer run:learning-pass-skipped, faltou um proposer — não é um erro da run.applied caem em MEMORY.md e USER.md dentro do diretório da run, exatamente o store da Lição 07. Re-rode o mesmo escopo: re-propor um fato já existente é um no-op de sucesso (reforçar, não duplicar), então o store não cresce — é a sua afirmação de “Sua vez” provada pela CLI. Para inspecionar o balde failed, leia o log estruturado (run:learning-pass-done) — onde a sua matriz do quadrante “gate sim, store não” aparece num run real. [uncertain] o caminho exato do run-dir depende do seu --data-dir/config; use alembic runs list para localizar a run e alembic tail <run-id> para reler o log.Quer ir além do resumo de uma linha? O passe inteiro que você reconstruiu também é exercitado pelos testes do kernel — pnpm --filter @alembic/hermes test roda os casos do review.test.ts (a fronteira 0.69/0.70, failed vs rejected, fail-closed nos erros de proposer/gate) que este lab destilou. É a prova de que o comportamento que você montou na mão é o comportamento que roda.
Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
ReviewDeps?proposer (ReviewProposer), gate (ReviewGate), memory (MemoryStore). Todas injetadas, todas obrigatórias (review.ts:30-37).scoreThresholdGate() default aprova?score ≥ 0.7, inclusivo. 0.70 aprova, 0.69 rejeita ("aprenda só de vitórias validadas", gate.ts:24-36).failed e rejected?rejected = o gate disse não. failed = o gate disse sim mas o store recusou (acima do orçamento). Correções opostas (review.ts:91-100).err e aborta o passe?proposer ou do gate: return proposed/verdict (review.ts:61, 90). O store fica intocado — nunca aprendizado pela metade.Sua vez — estenda o passe
Rode o passe duas vezes com a mesma proposta de score alto (ex.: conteúdo 'Build runs offline by default', score 0.9) contra a mesma instância de MemoryStore. Depois afirme:
- Ambos os passes reportam a proposta em
applied[](o gate a aprova das duas vezes). - Mas
memory.entries('memory')tem comprimento 1, não 2 — o dedup do próprio store faz a segunda gravação ser um sucesso no-op.
É a propriedade "reforce, não duplique" (Lição 8): o loop não adiciona nenhum dedup próprio — gravações aprovadas fluem pelo dedup existente do store, espelhando o ON CONFLICT DO UPDATE do mini-loop. O teste real que prova isso é "re-proposing an existing entry does not create a second entry" em review.test.ts:218-219.
op.content contenha uma palavra banida (uma política crua de "sem segredos na memória"), retornando ok({approved:false, reason:'contains banned term'}). Confirme que a proposta banida cai em rejected[] com sua razão, e que uma limpa ainda aplica. Você acabou de mostrar que o gate é o lugar para impor qualquer política de emissão — o Validator é só o exemplo mais rico (types.ts:122-130).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.
MemoryStore está acima do orçamento de chars e rejeita a gravação. Onde ela cai?rejected é uma recusa do gate; failed é "gate sim, store não". a colapsa os dois baldes — mas a causa (store cheio) e a correção (consolidar memória) são opostas à de uma recusa do gate. b confunde balde com fail-closed: só um erro do proposer/gate vira err; uma rejeição do store é registrada, não lançada. d viola a regra de "nenhum descarte silencioso" — a gravação cai em failed com a razão do store (review.ts:96-100).scoreThresholdGate(0.7). Depois o time entrega o Validator real do coda. Quanto de reviewAndLearn muda?@alembic/coda se liga depois fornecendo seu próprio ReviewGate — sem mudança em reviewAndLearn" (gate.ts docstring). a joga fora a injeção — depender de uma concretude é exatamente o que ADR-0009 proíbe. c inventa um quarto balde: o contrato de saída (LearnOutcome) não muda com o gate. d acopla portas independentes: trocar o gate não toca o proposer.err(new Error('model timed out')). O que acontece com a passagem e o store?reviewAndLearn checa if (!proposed.ok) return proposed (review.ts:61) — um erro do proposer faz short-circuit antes de qualquer gate ou gravação. a inventa "propostas até então": o erro vem do proposer, então não há proposta alguma para aplicar. b confunde fail-closed com balde — failed é só para gravações que o store recusou, não para erros de porta. c contradiz o contrato: o módulo nunca lança através da fronteira, só devolve err.applied nos dois), mas a segunda gravação é um sucesso no-op no store, então só há 1 entrada. b ignora o dedup do store. c erra a camada: o gate só olha o score, não duplicidade — quem deduplica é o store. d inventa um "conflito": o dedup é um sucesso silencioso (ok), não um failed. O teste "re-proposing an existing entry does not create a second entry" (review.test.ts:218) prova exatamente isso.ReviewGate?", "O que conta no orçamento de chars do MemoryStore?". É só dizer.