reviewAndLearn — o passe gated
Como um turno finalizado ensina o próximo — sem virar terra de ninguém. Um revisor propõe escritas duráveis de memória; um gate Validador dispõe; só as aprovadas sedimentam no MemoryStore. A restrição-chave — tirada direto da matriz de fusão e do ADR-0018 — é que o aprendizado é gated, não auto-aplicado. É um ADAPT do fork de background-review do Hermes para o estilo de portas do motor: sem thread de daemon, apenas três portas injetadas.
Esta lição destila esses arquivos lidos literalmente (rodapé com linhas exatas) mais a proveniência em docs/alembic-hermes-fusion-matrix.md §3, docs/hermes-complete-map.md §1.10/§5.1 e os ADRs. Por que importa pra missão: é a peça que torna o Alembic um motor que aprende sozinho sem que o modelo escreva o que quiser na memória.
- Explicar a forma propor → gate → aplicar e por que aprendizado é gated, não auto-aplicado.
- Distinguir os três baldes —
applied,rejected,failed— e por quefailed ≠ rejected. - Ler o controle de fluxo fail-closed: o que continua o passe e o que aborta o passe inteiro.
- Aplicar o gate padrão
scoreThresholdGate(0.7)e a fronteira inclusiva (0.69 vs 0.70).
01 · O que é, afinal, um "passe gated"
Imagine um estagiário que, ao fim de cada tarefa, escreve bilhetes do que aprendeu para o caderno permanente da equipe. Se ele pudesse colar qualquer bilhete direto no caderno, em pouco tempo o caderno estaria cheio de palpites errados e ruído. Então existe um editor: o estagiário propõe o bilhete, o editor aprova ou recusa, e só o aprovado entra no caderno. Esse editor é o gate; o caderno é o MemoryStore; o passe inteiro é o reviewAndLearn.
Por que "fechar" quando dá erro? (fail-closed vs fail-open)
Há duas posturas possíveis diante de um erro de infraestrutura no meio do aprendizado. O Alembic escolhe a conservadora — fail-closed: na dúvida, não aprenda. Compare:
02 · A forma: propor → gate → aplicar — com os três destinos
Agora a versão precisa. Cada proposta percorre o mesmo trilho, e termina em um de três baldes. O losango no gate é a decisão central: aprovou? Repare que a recusa do gate e a falha do store são caminhos diferentes — é o que a seção 05 vai destrinchar.
Guarde a leitura de cima: há um único ponto de decisão por proposta (o losango do gate) e dois jeitos de "não dar certo" depois dele — o gate recusar (→ rejected) ou o store recusar a escrita já aprovada (→ failed). Tudo o mais é o trilho.
03 · O driver pequeno e total
O reviewAndLearn é o orquestrador do passe. Ele é deliberadamente pequeno e total (nunca lança): um summary vazio ou zero propostas faz curto-circuito para um resultado vazio — um passe no-op é válido, espelhando o "Nothing to save." da fonte. Já um erro do proposer ou do gate falha o passe inteiro fechado (err):
// packages/hermes/src/learning/review.ts:54-70 export const reviewAndLearn = async ( summary: string, deps: ReviewDeps, ): Promise<Result<LearnOutcome, Error>> => { if (summary.trim().length === 0) return ok(emptyOutcome()); const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed; // erro do proposer ⇒ falha fechado if (proposed.value.length === 0) return ok(emptyOutcome()); const acc: OutcomeAcc = { applied: [], rejected: [], failed: [] }; for (const raw of proposed.value) { const stepErr = await processOne(raw, deps, acc); if (stepErr) return stepErr; // erro do gate / forma ruim ⇒ falha fechado } return ok({ applied: acc.applied, rejected: acc.rejected, failed: acc.failed }); };
As três portas injetadas vêm num único objeto ReviewDeps — nenhum adapter concreto, nenhuma construção de store dentro do módulo (é o kernel portável, ADR-0009):
// packages/hermes/src/learning/review.ts:29-37 export interface ReviewDeps { readonly proposer: ReviewProposer; // propõe escritas a partir do summary (prod: chamada de modelo) readonly gate: ReviewGate; // decide se cada proposta pode sedimentar (o Validador) readonly memory: MemoryStore; // store durável onde aprovadas são aplicadas (dedup reusado) }
As duas primeiras portas são tipos de função — assinaturas, não classes. Cada uma recebe uma entrada e devolve um Result (nunca lança). Ver a forma da assinatura deixa claro o que é injetável e por quê:
err (a chamada de modelo falhou) depois de o gate já ter aprovado e o store já ter escrito 2 propostas anteriores. O que reviewAndLearn devolve?
err imediatamente — nenhuma proposta foi sequer olhada, nenhum store foi tocado. Não existe "2 escritas anteriores": o erro do proposer é a primeira coisa que pode dar errado. Lição: propor é um passo único e atômico; o loop só começa depois que há uma lista de propostas em mãos.04 · Os três baldes
O resultado (LearnOutcome) separa três destinos, cada um com sentido próprio. Pense neles como três caixas no fim da esteira:
O tipo deixa isso explícito — e os comentários do código são a melhor legenda:
// packages/hermes/src/learning/types.ts:99-106 export interface LearnOutcome { /** Propostas aprovadas pelo gate E escritas com sucesso no store. */ readonly applied: readonly ReviewProposal[]; /** Propostas que o gate recusou, cada uma com a razão do gate. */ readonly rejected: readonly RejectedProposal[]; /** Propostas que o gate aprovou mas o store rejeitou (ex.: char-budget). */ readonly failed: readonly RejectedProposal[]; }
Trace passo a passo: um lote de 3 propostas
Veja o reviewAndLearn rodar sobre um lote concreto de três propostas — uma por balde. O for processa em ordem, e cada uma cai no seu balde sem afetar as outras (porque nenhuma é erro):
reviewProposalSchema.safeParse(raw). target='memory', op={action:'add',content:'x'}, score=0.9 ∈ [0,1] → parsed.success é true. (Se falhasse, seria return err e o passe abortaria.)const verdict = await deps.gate(proposal). Com o padrão scoreThresholdGate(): 0.9 >= 0.7 → ok({approved:true, reason:'score 0.9 ≥ threshold 0.7'}).if (!verdict.ok) return verdict; — é ok, então segue. Aprovou? verdict.value.approved é true, então não entra no ramo do rejected.const written = await deps.memory.apply(proposal.target, proposal.op). Store aceita → written.ok é true.failed e cai em acc.applied.push(proposal); retorna undefined → o passe continua para P2.apply retorna err). Em qual balde P3 cai, e o passe aborta? Faça antes de revelar.
failed[]; o passe NÃO aborta. Passo 1 ok (forma válida). Passo 2: 0.8 >= 0.7 → approved:true. Passo 4: memory.apply retorna err (over budget) → o ramo if (!written.ok) faz acc.failed.push({proposal, reason: written.error.message}) e return undefined. A chave: falha de store é recusa de infra, anotada em failed e o passe segue — só um err de gate ou de safeParse abortaria. É exatamente o teste "records a store-rejected approved write in `failed`".05 · Por que failed ≠ rejected (a distinção load-bearing)
Esta é a sutileza que carrega a observabilidade do subsistema. Tanto rejected quanto failed são "não entrou na memória" — mas por motivos opostos, e confundi-los esconderia bugs. Uma escrita acima do orçamento (infra) nunca pode ser lida como uma recusa de política (decisão do gate). O processOne separa os dois com cuidado cirúrgico:
// packages/hermes/src/learning/review.ts:78-103 — processOne const parsed = reviewProposalSchema.safeParse(raw); // saída de modelo NÃO confiável if (!parsed.success) return err(new Error(`Invalid review proposal: …`)); const proposal = parsed.data; const verdict = await deps.gate(proposal); if (!verdict.ok) return verdict; // ERRO do gate ⇒ falha o passe if (!verdict.value.approved) { acc.rejected.push({ proposal, reason: verdict.value.reason }); // gate disse NÃO return undefined; // continua o passe } const written = await deps.memory.apply(proposal.target, proposal.op); if (!written.ok) { acc.failed.push({ proposal, reason: written.error.message }); // store disse NÃO return undefined; // ainda continua } acc.applied.push(proposal);
processOne retorna Result<never, Error> | undefined. undefined significa "esta proposta foi tratada — continue o passe". Um Err significa "pare o passe inteiro, fechado". Então uma única proposta sendo rejeitada ou com falha de store não aborta o lote; só um erro de infraestrutura (forma de proposta ruim, falha do gate) faz isso. O teste "records a store-rejected approved write in `failed`" prova que a primeira escrita sobrevive enquanto o estouro vai para failed.As três maneiras de "não dar certo", lado a lado
Compare os três finais negativos. Repare: recusa (gate ou store) continua o passe e só anota num balde; erro aborta o passe inteiro. Esta tabela é o mapa de decisão do processOne:
| O que aconteceu | Sinal técnico | Balde | O passe... |
|---|---|---|---|
| Gate recusou a proposta | ok({approved:false}) | rejected[] | continua (anota razão) |
| Store recusou escrita aprovada | err de memory.apply | failed[] | continua (anota razão) |
| Gate quebrou (serviço caiu) | err do gate | — (nenhum) | aborta fechado |
| Proposta malformada | safeParse falha | — (nenhum) | aborta fechado |
ok({approved:false, reason:'too weak'}) para uma proposta. Onde ela vai parar?ok({approved:false}) → rejected[], e o processOne retorna undefined, então o passe segue. a erra o balde: failed[] é só para propostas que o gate aprovou mas o store não conseguiu escrever — aqui o store nem foi chamado. b confunde recusa com erro: só um err do gate abortaria o passe, e isto é ok(...). d contradiz o tipo: toda recusa carrega { proposal, reason } — não há descarte silencioso.06 · O gate padrão: aprender só com vitórias validadas
Até o Validator Gate real do @alembic/coda (ADR-0006) plugar seu próprio ReviewGate por injeção, o padrão conservador aprova uma proposta sse score ≥ 0.7 — a codificação mecânica de "learn only from validated wins", trazida do hermes-mini-loop (§5.1). É puro e total: sempre devolve ok(verdict), nunca lança.
// packages/hermes/src/learning/gate.ts:24-36 export const scoreThresholdGate = ( min: number = DEFAULT_REVIEW_SCORE_THRESHOLD, // 0.7 ): ReviewGate => { return async (proposal) => { const approved = proposal.score >= min; // fronteira INCLUSIVA const reason = approved ? `score ${proposal.score} ≥ threshold ${min}` : `score ${proposal.score} < threshold ${min} (learn only from validated wins)`; return ok({ approved, reason }); // puro & total: sempre ok(verdict) }; };
A fronteira é inclusiva — 0.69 reprova, 0.70 passa
O operador é >=, não >. Então exatamente 0.70 aprova; qualquer coisa abaixo reprova. Visualize a reta do score com o piso em 0.7:
E a reason não é decorativa: é a string que explica a decisão no outcome (observabilidade). O ramo aprova/recusa escolhe qual template de razão é interpolado:
scoreThresholdGate() padrão. Aprova ou reprova? E qual a razão registrada?
approved = 0.70 >= 0.7 é true (a fronteira é inclusiva). A razão registrada é "score 0.7 ≥ threshold 0.7". Se fosse 0.69, reprovaria com "score 0.69 < threshold 0.7 (learn only from validated wins)". O teste "rejects 0.69 and approves 0.70 at the default 0.7 floor" fixa exatamente essa fronteira.scoreThresholdGate() padrão. O resultado?approved = score >= min com min = 0.7; 0.69 está abaixo do piso inclusivo, então reprova com a razão "learn only from validated wins". a inventa arredondamento — a comparação é numérica exata, sem round. c viola a invariante "puro & total": o gate sempre retorna ok(verdict), nunca lança. d não existe: o verdict só tem approved + reason; não há estado "arriscada". Exatamente 0.70 aprovaria.07 · A sutileza: o veredito é dado, não o Result
Aqui mora a peça mais fácil de errar — e a mais importante. Há dois eixos independentes no retorno do gate. O envelope Result (ok vs err) diz se o gate funcionou. O dado dentro dele (verdict.approved = true/false) carrega a decisão. Uma rejeição é ok({approved:false}) — não err.
É exatamente por isso que reviewAndLearn consegue distinguir "a política recusou isto" (uma rejeição legítima, esperada, anotada em rejected) de "a máquina da política quebrou" (o serviço do Validador caiu — um err que para tudo, fechado). Se rejeição e erro compartilhassem o canal do Result, um Validador fora do ar pareceria "rejeitou tudo" e o passe seguiria gravando vazio em silêncio. O teste "returns err when the gate errors (and stops the pass closed)" prova o lado do erro.
08 · A máquina de estados de uma proposta (interativa)
Junte tudo numa simulação. Escolha um cenário abaixo: a figura acende o caminho que aquela proposta percorre e os baldes mostram onde ela cai (ou se o passe inteiro aborta). É o mesmo controle de fluxo do processOne, vivo.
if (!verdict.ok) return verdict; corta antes de olhar approved. É a matriz 2×2 da seção 07 em movimento — err vence o eixo do verdict toda vez.09 · Reforçar, não duplicar
O loop não adiciona nenhuma lógica de dedup própria. Escritas aprovadas fluem pelo dedup já existente do MemoryStore (Lição 07): re-propor uma entrada que já existe é um no-op de sucesso — então é contado como applied, mas o store permanece em uma entrada. Isso espelha a intenção do ON CONFLICT DO UPDATE do mini-loop: reforçar, não empilhar duplicatas.
O schema da proposta valida a saída do modelo na fronteira, antes de qualquer gate ou escrita — incluindo score limitado a [0,1] e o alvo restrito a 'memory' ou 'user'. Um score: 1.5 de um modelo mal-comportado é rejeitado pelo safeParse (e aborta o passe, não vira rejeição de política):
// packages/hermes/src/learning/types.ts:62-71 export const reviewProposalSchema = z.object({ target: memoryTargetSchema, // 'memory' (notas do agente) ou 'user' (fatos do usuário) op: memoryOpSchema, // add / replace / remove rationale: z.string(), // por que vale lembrar — vai pro outcome (observabilidade) score: z.number().min(0).max(1), // confiança do revisor; o gate compara com o piso });
O op é uma união discriminada por action: cada operação tem campos distintos, e o schema só aceita uma das três formas. É o que o safeParse verifica antes de o gate sequer rodar:
MemoryStore em vez de adicionar o seu próprio?applied) mas não cresce o store. a e b são racionalizações superficiais — o motivo real é evitar duas políticas divergentes, não preguiça nem impossibilidade. c contradiz o código: processOne só chama memory.apply e confia no dedup do store. Provado pelo teste "re-proposing an existing entry does not create a second entry".10 · ADAPT, não port literal — Hermes vs Alembic
Por que "ADAPT" e não "CLONE"? Porque a disciplina foi copiada, mas o encanamento foi remodelado para o motor. O Hermes forka uma thread de background (um daemon Python no runtime AIAgent); o Alembic não tem esse runtime para forkar, então a mesma forma propor→gate→aplicar virou três portas injetadas. Compare lado a lado:
| Peça | Hermes (origem) | Alembic (ADAPT) |
|---|---|---|
| Gatilho | fork de thread de background | chamada de função reviewAndLearn(summary, deps) |
| Propor | daemon no runtime AIAgent (Python) | porta ReviewProposer (1 chamada de ModelAdapter em prod) |
| Dispor | filtro score >= 0.7 do mini-loop | porta ReviewGate (padrão scoreThresholdGate(0.7)) |
| Sedimentar | ON CONFLICT DO UPDATE (SQL) | dedup do MemoryStore reusado |
| Dependências | concretas no runtime | injetadas; nenhum adapter/store concreto importado (ADR-0009) |
Quem governa cada propriedade — o mapa de ADRs
Cada decisão deste kernel rastreia até uma fonte de verdade no repositório. Este mapa liga propriedade → documento que a fixa, para você não confiar em memória:
Confusões comuns
score ≥ 0.7), e o Validador real do coda (ADR-0006) pode substituí-lo por injeção sem tocar neste kernel.ReviewProposer, ReviewGate, MemoryStore). A disciplina é idêntica; o encanamento serve ao motor.11 · Como isso se encaixa
Até aqui você olhou o reviewAndLearn por dentro. Agora afaste a câmera: onde esse passe vive na esteira do motor? A resposta curta — ele é o último elo do loop fechado, o que faz a run de hoje deixar uma marca durável para a run de amanhã. Ele só roda depois que todos os gates passaram, e o que ele produz é lido de volta no começo do próximo turno como contexto.
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 reviewAndLearn é 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, abra o mapa interativo da metodologia.
- Lição 04 · O loop fechado — é o ciclo que contém este passe;
reviewAndLearné o último elo (“decidir → recomeçar com o aprendido”) que fecha o loop. - Lição 07 · MemoryStore — é o store que ele alimenta; o passe não tem dedup próprio, reusa o do store (a seção 09 mostra “reforçar, não duplicar”).
- Lição 17 · A pipeline de gates — é onde o
ReviewGatese pluga; o mesmo Validador (ADR-0006) que reprova milestones pode reprovar uma escrita de memória. - Lição 23 · Lab: o passe de aprendizado — é o lab prático onde você reconstrói este kernel do zero, com testes, fixando os três baldes na mão.
12 · Na prática
Chega de teoria — rode o passe. O aprendizado é opt-in: uma run normal não aprende. Você liga o passe gated com a flag --learn, e roda tudo hermético ($0, sem rede) com --offline. Nesse modo, o proposer é o adapter offline determinístico e o gate é o Validador — exatamente o caminho que você acabou de estudar:
# 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: imprime learning gate: ${applied} applied, ${rejected} rejected. Os números vêm direto dos baldes do LearnOutcome (seção 04). O balde failed não entra nessa linha-resumo — ele fica no log estruturado run:learning-pass-done (com applied/rejected/failed). No offline o resultado é determinístico — os contadores exatos dependem do que o proposer offline gera para o seu GOAL.md.Dois detalhes que conectam direto com as seções anteriores. Primeiro: sem a flag, nada acontece — o aprendizado é aditivo e desligado por padrão, então uma run verde continua verde. Segundo: se não houver adapter de proposer disponível, o passe é pulado (log run:learning-pass-skipped), nunca quebra a run — é a mesma postura fail-closed da seção 05 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.learning gate: N applied, N rejected na saída. applied = aprovadas pelo gate e escritas; rejected = o gate disse não (política). Se aparecer run:learning-pass-skipped, faltou um proposer — não é um erro da run.applied caem em MEMORY.md (notas do agente) e USER.md (fatos do usuário) 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 (a seção 09: reforçar, não duplicar), então o store não cresce. [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 também é exercitado pelos testes do kernel — pnpm --filter @alembic/hermes test roda os 14 casos do review.test.ts (a fronteira 0.69/0.70, failed vs rejected, fail-closed nos erros de proposer/gate) que esta lição destilou. É a prova de que o comportamento que você leu é 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.
MemoryStore. Só o aprovado sedimenta (ADR-0018).failed vs rejected?rejected = o gate disse não (política). failed = o gate aprovou mas o store não escreveu (infra, ex.: orçamento). Distintos de propósito.score ≥ 0.7 (DEFAULT_REVIEW_SCORE_THRESHOLD). Inclusivo: 0.70 aprova, 0.69 reprova. "Learn only from validated wins."err?ok({approved:false}). Result diz se o gate funcionou; verdict.approved diz a decisão. err só = gate quebrou → aborta.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.
safeParse falha). O que acontece com as outras?processOne retorna err, o stepErr é truthy e reviewAndLearn retorna na hora (linha 67). As propostas seguintes nem são olhadas. a/b confundem erro de forma (aborta) com recusa (anota em balde e segue) — malformação não é nem rejected nem failed. d é o comportamento "tolerante" que o design rejeita: fail-closed significa parar, não ignorar.verdict.approved; o envelope Result (ok/err) só diz se o gate funcionou. b inverte os eixos: tanto aprovação quanto recusa são ok(...) — o que muda é o approved lá dentro. c viola "puro & total": o gate nunca lança. d confunde o efeito (a proposta acaba em rejected) com a causa (o booleano do verdict).reviewAndLearn é um ADAPT do Hermes, não um CLONE literal?reviewAndLearn(summary, deps) com ReviewProposer/ReviewGate/MemoryStore injetados. a erra os números: o piso continua 0.7, igual ao mini-loop. c descreveria um CLONE literal — justamente o que não é, já que a estrutura mudou. d contradiz o ADR-0018: continua gated, nunca auto-aplica.ReviewGate?", "O que entra num summary de turno?". É só dizer.