reviewAndLearnComo um turno que terminou ensina o próximo. Um revisor propõe escritas duráveis de memória; um gate do Validator dispõe; o que for aprovado se sedimenta no MemoryStore. A restrição-mestra — tirada direto da matriz de fusão e do ADR-0018 — é que aprender é gated, não auto-apply. Este é um ADAPT do fork de revisão em background do Hermes para o estilo de ports do motor: sem thread daemon, só três ports injetados.
review.ts + gate.ts + types.ts, com a prova em review.test.ts
Tudo nesta página é citado de arquivo real do monorepo. O único conceito em torno do qual a lição inteira gira é o passo de aprendizado com gate: o modelo só propõe; um gate decide; só o aprovado vira escrita durável. Nada aqui é inventado.
propor → gate → aplicar de reviewAndLearn e dizer o que faz o passo falhar fechado.applied, rejected, failed — e por que failed ≠ rejected.scoreThresholdGate() e justificar o piso inclusivo em 0,7.Result (a engrenagem funcionou?) do verdict.approved (qual foi a decisão?).MemoryStore guarda escritas duráveis e já faz dedup.Result<T, E> é "ou deu certo (ok) ou deu errado (err)" — uma união discriminada que nunca lança.Imagine um turno de trabalho que acabou. O agente resume o que aconteceu e, dessa lição, quer guardar algumas coisas para o futuro: "tal abordagem funcionou", "tal arquivo é o ponto certo". A pergunta perigosa é: guardamos tudo que o modelo sugere?
A resposta do Alembic é um não firme. O modelo apenas propõe. Antes de qualquer coisa virar memória durável, uma porta de controle — o gate — decide se a proposta é boa o bastante. Só o que passa é escrito. É por isso que chamamos de passo de aprendizado com gate.
Analogia. Pense num caderno de aprendizados de uma equipe. Qualquer pessoa pode sugerir uma anotação numa folha de rascunho. Mas só o revisor decide o que é copiado a limpo no caderno oficial. O rascunho fica cheio de ideias; o caderno só recebe o que vale. O modelo escreve no rascunho; o gate é o revisor.
A função pública é reviewAndLearn(summary, deps) e devolve Promise<Result<LearnOutcome, Error>>. Ela é pequena e total: recebe um resumo de texto e três ports em deps — o proposer (que em produção embrulha uma chamada de modelo), o gate (a política de aprovação) e o memory (o MemoryStore). Nenhum daemon, nenhuma thread: a mesma forma do Hermes reescrita como ports injetados.
O Hermes faz isso forkando uma thread de revisão em background dentro do seu AIAgent em Python. O Alembic não tem esse runtime, então a mesma disciplina (propor → gate → aplicar) é remodelada em ports puros e testáveis. A disciplina é idêntica; o encanamento cabe no motor.
O fluxo tem três estágios e três destinos. Cada proposta percorre os estágios e cai em um de três baldes de resultado. Veja o desenho inteiro antes do código:
O caminho de uma proposta: do proposer ao gate, e do gate para um dos três baldes. Erro de infraestrutura para o passo; recusa de política apenas registra e continua.
rejected; o memory.apply manda para applied ou failed.O driver é pequeno e total. Um resumo vazio ou zero propostas curto-circuita para um resultado vazio (um passo no-op é válido); um erro do proposer ou do gate faz o passo inteiro falhar fechado:
// packages/hermes/src/learning/review.ts — reviewAndLearn 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 }); };
Trecho citado verbatim de review.ts. Note os dois return que saem cedo: resumo vazio e zero propostas.
O proposer devolve ok([]) — uma lista vazia de propostas (não houve nada a salvar). O que reviewAndLearn retorna?
ok(emptyOutcome()) — um resultado de sucesso com os três baldes vazios. Lista vazia não é erro: um passo no-op é válido. Só um err do proposer (a engrenagem quebrou) derruba o passo.ok vazio (nada a fazer) e um para err (a máquina quebrou) — tudo antes de tocar no laço."Total" quer dizer que toda entrada tem uma saída bem definida — a função nunca lança, nunca trava num caso não tratado. Resumo vazio, zero propostas, erro do proposer, erro do gate, escrita recusada: cada um tem um caminho explícito. É isso que torna o passo seguro de chamar a cada turno sem try/catch em volta.
O for não decide nada sozinho: ele chama processOne(raw, deps, acc) para cada proposta crua e só observa uma coisa — se voltou um stepErr (um Err), o passo inteiro para e devolve esse erro. Caso contrário, segue. Toda a lógica de "qual balde" vive em processOne, que vamos abrir agora.
failed ≠ rejectedO resultado separa três destinos. A distinção entre rejected (o gate disse não) e failed (o gate disse sim, mas o store não conseguiu gravar) é load-bearing para observabilidade — uma escrita estourada de orçamento nunca pode ser confundida com uma recusa de política:
// packages/hermes/src/learning/review.ts — 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 ⇒ derruba o passo if (!verdict.value.approved) { acc.rejected.push({ proposal, reason: verdict.value.reason }); // gate disse NÃO return undefined; // continua o passo } 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 devolve Result<never, Error> | undefined. O undefined significa "esta proposta foi tratada — continue o passo". Um Err significa "pare o passo inteiro fechado". Ou seja: uma única proposta ser rejeitada ou falhar no store não aborta o lote; só um erro de infraestrutura (forma de proposta inválida, falha do gate) aborta.| Balde | Quem decidiu | O que aconteceu | O passo… |
|---|---|---|---|
| applied | store gravou | gate aprovou e memory.apply deu ok | continua |
| rejected | gate | gate devolveu ok({approved:false}) — política recusou | continua |
| failed | store | gate aprovou, mas memory.apply deu err (ex.: orçamento) | continua |
| (passo falha) | infraestrutura | forma inválida ou gate devolveu err | para fechado |
err) param o passo.O gate devolveu ok({approved:true}) e memory.apply devolveu ok. A proposta entra em applied e o passo segue para a próxima.
Cinco propostas entram juntas. Nenhuma rejeição nem falha de store derruba o lote — cada uma só cai no seu balde, e reviewAndLearn devolve ok com a contagem dos três:
ok.failed com rejected. Se você tratar os dois como "não entrou", perde a informação mais útil: por que não entrou. Recusa de política e falha de gravação pedem ações diferentes — uma é decisão, a outra é incidente.Até o Validator Gate real do @alembic/coda (ADR-0006) plugar o seu próprio ReviewGate, o padrão conservador aprova uma proposta se e somente se score ≥ 0,7 — a codificação mecânica de "aprender só de vitórias validadas", herdada do hermes-mini-loop:
// packages/hermes/src/learning/gate.ts — scoreThresholdGate 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) }; };
À esquerda do piso, reprova; em 0,70 exato, aprova (a fronteira é inclusiva).
O número não está cravado no gate: ele vem de DEFAULT_REVIEW_SCORE_THRESHOLD = 0.7 em learning/types.ts. scoreThresholdGate() usa esse default, mas aceita um min por parâmetro — então um chamador pode endurecer ou afrouxar o piso sem tocar no kernel.
Quando o Validator real do coda entrar, ele não reescreve esta função: ele injeta o seu próprio ReviewGate em deps.gate. scoreThresholdGate() é só a linha de base segura para quando ninguém forneceu um gate. É o mesmo padrão de ports da cintura estreita — trocar a política por injeção.
score: 0,92. 0,92 ≥ 0,7 → aprovada. Segue para memory.apply.score: 0,55. 0,55 ≥ 0,7 é falso → reprovada, vai para rejected com a razão "learn only from validated wins".score: 0,70. A fronteira é inclusiva → aprovada. Por pouco, mas entra.score: 0,699. Aprova ou reprova? Reprova — está abaixo de 0,7, e o piso é inclusivo só em 0,7, não abaixo.Esta é a sutileza que vale internalizar. O gate devolve ok({approved:false, …}) para uma reprovação — não err. O invólucro Result sinaliza se o gate funcionou; o verdict.approved carrega a decisão. Um gate que deu erro (ex.: o serviço do Validator caiu) devolve err e para o passo.
Duas camadas de sinal. O Result diz se a máquina rodou; o verdict diz o que ela decidiu. Só se lê o veredito quando o Result é ok.
É exatamente isso que deixa reviewAndLearn distinguir "a política recusou isto" de "a maquinaria da política quebrou". Se uma reprovação fosse err, qualquer proposta fraca derrubaria o passo inteiro — e você não conseguiria coletar o resto do lote nem saber por que cada uma caiu. Reprovação é ok({approved:false}) e vira rejected; erro é err e para tudo.
return ok({ approved: false, reason: 'score 0.55 < 0.7' }); // → rejected[], passo segue
return err( new Error('Validator offline') ); // → passo PARA fechado
ok({approved:false, reason}) — um Result de sucesso cuja decisão é "não". Nunca err.err?Result sinaliza, então?verdict.approved, dentro do valor.O laço não tem lógica de dedup própria. Escritas aprovadas fluem pelo dedup que já existe no MemoryStore — repropor uma entrada que já está lá é um no-op de sucesso (então conta como applied, mas o store continua com uma entrada). Isso espelha a intenção do ON CONFLICT DO UPDATE do mini-loop: reforçar, não empilhar duplicatas.
reviewProposalSchema (em learning/types.ts) valida a saída do modelo, incluindo score limitado a [0, 1] — um score: 1.5 de um modelo malcomportado é rejeitado antes de qualquer escrita.score fora de [0,1] nunca chega ao gate.score ≥ 0,7), e o Validator real do coda pode substituí-lo por injeção sem tocar neste kernel.AIAgent em Python, então a mesma forma propor → gate → aplicar vira ports injetados (ReviewProposer, ReviewGate, MemoryStore). A disciplina é idêntica; o encanamento cabe no motor.failed é o mesmo que rejected." Não. rejected é uma decisão de política (o gate disse não). failed é um incidente de escrita (o gate disse sim, mas o store não conseguiu — ex.: orçamento). Tratá-los igual apaga a informação que importa.| Afirmação | Veredito | Porquê |
|---|---|---|
| Uma proposta reprovada derruba o passo inteiro. | Falso | Reprovação é ok({approved:false}) → rejected; o passo continua. |
Um score: 1.5 chega ao gate. | Falso | O schema barra score fora de [0,1] antes do gate. |
| Repropor uma entrada existente cria duplicata. | Falso | O dedup do store faz disso um no-op de sucesso. |
| Um erro do gate faz o passo falhar fechado. | Verdadeiro | err do gate é retornado e para tudo. |
Um turno termina e sugere lições. Um revisor decide o que vale guardar. Só o aprovado entra no caderno oficial. As sugestões recusadas ficam registradas (para você saber o que foi descartado), e se a caneta falhar na hora de escrever, isso é anotado à parte — não é a mesma coisa que "o revisor disse não".
reviewAndLearn(summary, deps) chama deps.proposer para obter ReviewProposal[], valida cada uma com reviewProposalSchema, passa por deps.gate (default scoreThresholdGate(0.7)) e, se verdict.approved, chama deps.memory.apply(target, op). Resultado: LearnOutcome{applied, rejected, failed}. err só em forma inválida ou erro do gate; falha de escrita vira failed.
ok({approved:false, reason:'fraca'}). Onde a proposta vai parar?ok({approved:false}) → rejected[]. failed[] é reservado para propostas que o gate aprovou mas o store não gravou. Só um erro do gate (err) abortaria o passo.score: 0,69 contra o scoreThresholdGate() padrão. O resultado?approved = score >= min com min = 0,7. 0,69 está abaixo do piso inclusivo, então é reprovada com a razão "learn only from validated wins". Exatamente 0,70 aprovaria.MemoryStore em vez de ter o seu?applied) mas não faz o store crescer — espelhando o ON CONFLICT DO UPDATE do mini-loop.Result ao redor do veredito do gate sinaliza?Result diz se a maquinaria do gate rodou (ok) ou quebrou (err). A decisão aprovar/reprovar é dado dentro do valor — verdict.approved —, não o invólucro.propor → gate → aplicar, pequena e total; erro de infraestrutura faz falhar fechado.applied, rejected, failed — e failed (incidente) ≠ rejected (decisão).score ≥ 0,7, piso inclusivo, trocável por injeção pelo Validator do coda.Result diz se a engrenagem rodou; verdict.approved diz a decisão — recusa é ok, nunca err.A prova desta lição não é a leitura — é a suíte. O arquivo review.test.ts tem 14 casos que fixam cada afirmação feita aqui: a fronteira 0,69/0,70, failed vs rejected, reforçar-não-duplicar e o fail-closed em erros de proposer/gate. Rode os testes do pacote:
# a prova do passo de aprendizado, isolada pnpm --filter @alembic/hermes test # a baseline completa do monorepo pnpm -r typecheck && pnpm -r build && pnpm -w test
rejected de failed não é purismo: é observabilidade.MemoryStore — gated, observável em três baldes, e falho-fechado quando a máquina quebra.MemoryStore e o seu dedup; a lição 9 (Curadoria profunda) mostra como o sistema escolhe o que vale ranquear. As três juntas formam o ciclo de aprendizado do motor.