No Lab 1 (lição 22) você construiu um store. Agora você conecta a pedra angular da fusão (lições 4 e 8): um passo fechado de auto-melhoria onde um revisor propõe escritas duráveis, um gate dispõe, e as escritas aprovadas sedimentam no store. Você vai montar o reviewAndLearn a partir de três ports injetados — um proposer falso, o gate padrão, e o MemoryStore real do Lab 1 — e ver um turno se dividir nos baldes applied / rejected / failed. Esta é a API que de fato existe no repo (@alembic/hermes); você a chama exatamente como a produção chama.
MemoryStore rodando sobre um FsPort falso. Vamos reusá-lo aqui.async/await e checar if (r.ok). Nada além disso — explicamos cada peça do zero.reviewAndLearn a partir dos seus três ports (proposer, gate, memory).rejected (o gate disse não) é diferente de failed (o gate disse sim, o store recusou).err).O aprendizado no Alembic é gated, não auto-apply (ADR-0018). O modelo propõe; um Validator dispõe. Ao construir o passo a partir de peças falsas, você vê exatamente por que uma proposta rejeitada é diferente de uma escrita que falhou — e por que ambas são diferentes de um erro que aborta o passo.
O driver é uma função pura de orquestração que nunca lança através da fronteira: reviewAndLearn(summary, deps): Promise<Result<LearnOutcome, Error>>. Tudo que ele precisa chega em deps — não há nenhum import de adapter concreto nem construção de store concreto (invariante ②, ADR-0009).
export const reviewAndLearn = async ( summary: string, deps: ReviewDeps, ): Promise<Result<LearnOutcome, Error>> => { /* … */ };
O passo de aprendizado: propor → gate → aplicar, e a saída sempre se separa em três baldes nomeados.
O driver reviewAndLearn(summary, deps) depende só de ports injetados. Seu ReviewDeps tem exatamente três campos — e os três são obrigatórios:
| Port | Tipo | Na produção | Neste lab |
|---|---|---|---|
| proposer | ReviewProposer | uma chamada de ModelAdapter, no formato da cintura estreita | um fake devolvendo propostas fixas |
| gate | ReviewGate | o Validator do @alembic/coda (ADR-0006) | scoreThresholdGate(0.7) (o padrão que vem no pacote) |
| memory | MemoryStore | o store durável, file-backed | um MemoryStore real sobre um FsPort falso |
Dos três ports, qual é o único que neste lab será real (não um fake)?
memory. Usamos o MemoryStore de verdade (sobre um FsPort falso, como no Lab 1) justamente para ver o dedup real e o limite de caracteres real entrarem em jogo. O proposer e o gate são triviais o bastante para serem fakes — e o scoreThresholdGate que usamos como "fake" é, na verdade, o próprio padrão do pacote.Antes de codar, fixe o mapa mental. Um resumo de turno entra; cada proposta segue um caminho; a saída sempre cai num de três baldes — ou o passo inteiro aborta com err.
applied/rejected/failed são resultados normais de um passo bem-sucedido. Um err (proposer erra, gate erra, ou proposta com forma inválida) não é um balde — é o passo inteiro falhando fechado, com o store intacto.O MemoryStore real do @alembic/hermes precisa de um FsPort. Reusamos o mesmo fake Map-backed do Lab 1, e então chamamos load() uma vez para inicializar o estado vivo.
import { MemoryStore, reviewAndLearn, scoreThresholdGate, } from '@alembic/hermes'; import { ok, err } from '@alembic/contracts'; import type { ReviewProposal, ReviewProposer } from '@alembic/hermes'; const memory = new MemoryStore(makeFakeFs(), '/agent'); // FsPort falso do Lab 1 await memory.load(); // inicializa o estado vivo
load() primeiro? O MemoryStore mantém um snapshot congelado capturado no load() (para o cache do prompt) e um estado vivo que o apply() muta. Sem o load() inicial o store não está pronto. Construtor: new MemoryStore(fs, baseDir, options?) — o terceiro argumento (opções) é onde, no Passo 4, vamos apertar o limite de caracteres.load() deixa o store pronto: um snapshot congelado para o prompt e o estado vivo que cada escrita aprovada muta.MemoryStore, reviewAndLearn e scoreThresholdGate de @alembic/hermes.FsPort falso e chamei await memory.load().Na produção o proposer embrulha uma chamada de modelo e traduz o resultado dela num Result. No lab ele só devolve propostas fixas. Cada proposta é um { target, op, rationale, score } — o score ∈ [0,1] é a confiança do próprio revisor, e o gate decide se ela cruza o piso.
const fakeProposer: ReviewProposer = async (_summary) => { const proposals: ReviewProposal[] = [ { target: 'memory', op: { action: 'add', content: 'Build roda offline por padrão' }, rationale: 'observado neste run', score: 0.9 }, // forte → deve APLICAR { target: 'memory', op: { action: 'add', content: 'talvez preferir tabs?' }, rationale: 'palpite', score: 0.4 }, // fraco → deve REJEITAR ]; return ok(proposals); };
O port ReviewProposer devolve Result<readonly ReviewProposal[], Error> e nunca lança. Uma falha de modelo, na produção, vira err(...) — que faz o passo inteiro falhar fechado. Embrulhar o array em ok diz "o revisor rodou com sucesso e aqui está a saída dele". O score é validado pelo Zod (z.number().min(0).max(1)) antes de qualquer escrita, porque em produção ele vem de saída de modelo — não confiável.
export const reviewProposalSchema = z.object({ target: memoryTargetSchema, // 'memory' | 'user' op: memoryOpSchema, // add | replace | remove rationale: z.string(), score: z.number().min(0).max(1), // fora de [0,1] → err });
target pode ser 'memory' (notas do agente) ou 'user' (fatos sobre o usuário). Uma proposta com target: 'user' sedimenta no store USER, não no MEMORY — o roteamento por target também é coberto por teste no repo.score é só a confiança do revisor — o Zod o valida em [0,1], e quem decide é o gate.Agora monte os três ports e rode um passo. O gate padrão aprova score ≥ 0.7, então a proposta 0.9 é aplicada e a 0.4 é rejeitada.
const result = await reviewAndLearn('terminei uma unidade; testes verdes', { proposer: fakeProposer, gate: scoreThresholdGate(0.7), // o padrão conservador do pacote 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 roda offline por padrão'] }
O store agora guarda exatamente a escrita 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.
Toda rejeição vem com um texto. O gate de score monta, no caso de recusa, exatamente score <n> < threshold <min> (learn only from validated wins) — a codificação mecânica de "só aprenda com vitórias validadas". Um operador lendo o rejected[] entende por que cada proposta não entrou.
score >= min, não >.Se você trocar o piso para scoreThresholdGate(0.4), onde cai a proposta de score 0.4?
applied[]. O piso é inclusivo: score >= min. Com min = 0.4, a proposta de exatamente 0.4 aprova (o teste do repo fixa isso checando que 0.69 rejeita e 0.70 aprova no piso padrão). E, se o store aceitar a escrita, ela vira applied de fato.failed (gate sim, store não)A distinção sutil: rejected = o gate disse não; failed = o gate disse sim, mas o store não conseguiu escrever (por exemplo, estourou o orçamento de caracteres). Para vê-lo, encolha o limite do store de modo que uma escrita aprovada transborde. O passo ainda tem sucesso no geral — só aquela escrita cai em failed:
const tiny = new MemoryStore(makeFakeFs(), '/agent', { memoryCharLimit: 12 }); await tiny.load(); const r = await reviewAndLearn('turno', { proposer: async () => ok([{ target: 'memory', op: { action: 'add', content: 'uma frase bem mais longa que doze caracteres' }, rationale: 'x', score: 0.95 }]), // o gate APROVA (0.95 ≥ 0.7)… gate: scoreThresholdGate(), memory: tiny, }); // …mas o store recusa a escrita acima do orçamento: // r.value.applied = [] r.value.rejected = [] // r.value.failed = [{ proposal, reason: '…would exceed the limit…' }]
Colapsar failed dentro de rejected diria a um operador "a política recusou esta escrita" quando a verdade é "a política aprovou, mas o store está cheio". As duas situações pedem correções diferentes — relaxar o gate vs. consolidar a memória. O teste que vive no repo, "records a store-rejected approved write in `failed`, not as a throw", fixa exatamente essa separação: ele usa memoryCharLimit: 12, confere que failed tem comprimento 1 e que a razão casa com /exceed the limit/.
failed não é um erro. O passo continua e devolve ok. Se você tratar failed.length > 0 como "o passo quebrou", você vai parar um loop perfeitamente saudável só porque uma nota não coube.failed com a razão do store, e o passo segue ok.Três coisas abortam o passo inteiro com err (e não num balde): um erro do proposer, um erro do gate, e uma proposta com forma inválida (o Zod recusa). Tente um proposer que devolve err:
err abortam e deixam o store intacto; só a recusa do store continua, registrando failed.const bad = await reviewAndLearn('turno', { proposer: async () => err(new Error('modelo deu timeout')), gate: scoreThresholdGate(), memory, }); // bad.ok === false — o passo inteiro falhou fechado; o store fica intacto.
O driver checa o resultado do proposer antes de qualquer gate ou escrita: const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed;. Um erro do proposer propaga na hora. Dentro do laço, processOne devolve err em dois casos — proposta com forma inválida (safeParse falha) e gate que devolve err — e o laço propaga (if (stepErr) return stepErr;). Já uma recusa do store é gravada em acc.failed e devolve undefined, de modo que o passo continua.
const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed; // proposer erra → aborta if (proposed.value.length === 0) return ok(emptyOutcome()); // dentro de processOne, por proposta: if (!parsed.success) return err(/* forma inválida */); const verdict = await deps.gate(proposal); if (!verdict.ok) return verdict; // gate erra → aborta if (!verdict.value.approved) { acc.rejected.push(…); return; } const written = await deps.memory.apply(…); if (!written.ok) { acc.failed.push(…); return; } // store recusa → failed, NÃO aborta acc.applied.push(proposal);
E um resumo vazio ou zero propostas é um no-op válido — ok com os três baldes vazios, espelhando o "Nothing to save." da fonte. (Um resumo só com espaços faz o driver retornar antes mesmo de chamar o proposer.)
err.ok com três baldes vazios, não um erro.Mexa nos controles e veja em qual balde a proposta cai. Replica a lógica exata do driver: o gate compara score ao piso (inclusivo); se aprovar, o store tenta escrever; se o conteúdo passar do memoryCharLimit, a escrita vai para failed.
review.test.ts: 0.69 vs 0.70 no piso, a escrita acima do orçamento em failed, e a aprovação limpa em applied. O demo não simula a lógica — ele a repete.Vamos rodar, no papel, um único passo com quatro propostas e um store de orçamento apertado. Acompanhe cada proposta até seu balde.
score 0.9, conteúdo curto. Gate: aprova (0.9 ≥ 0.7). Store: cabe. → applied.score 0.4. Gate: recusa (0.4 < 0.7), razão "learn only from validated wins". → rejected.score 0.95, conteúdo longo demais. Gate: aprova. Store: estoura o limite, recusa com "…would exceed the limit…". → failed.score 0.8, conteúdo idêntico ao da A. Gate: aprova. Store: dedup → no-op de sucesso. → applied (mas o store continua com 1 entrada, não 2).applied = [A, D], rejected = [B], failed = [C], e result.ok === true. memory.entries('memory') tem 1 entrada.result.ok é true.Sem rodar nada: uma Proposta E chega com score: 1.3. Em qual balde ela cai?
score: 1.3 viola z.number().min(0).max(1), então o safeParse falha e o passo inteiro retorna err — antes de qualquer gate ou escrita. Forma inválida é fail-closed, igual a um erro de proposer. O teste "returns err on a structurally invalid proposal (out-of-range score)" fixa esse caminho.'Build roda offline por padrão', score 0.9), contra a mesma instância de MemoryStore.applied[] (o gate aprova toda vez).memory.entries('memory') tem comprimento 1, não 2 — o dedup do próprio store faz a segunda escrita ser um no-op de sucesso.ON CONFLICT DO UPDATE do mini-loop. O teste que prova isso no repo está no bloco "reviewAndLearn — reinforce, do not duplicate" de review.test.ts (re-propor uma entrada existente não cria uma segunda).applied), mas o dedup do store mantém uma única entrada.ReviewGate que recuse qualquer proposta cujo op.content contenha uma palavra banida (uma política grosseira de "nada de segredos na memória").ok({ approved: false, reason: 'contém termo banido' }) para a proposta banida — e aprove uma limpa.rejected[] com a sua razão, e a limpa ainda aplica.@alembic/coda é só o exemplo mais rico — mas como ele é um port, o seu gate caseiro e o Validator real entram pela mesma porta, sem tocar no reviewAndLearn.ReviewGate: nenhuma delas faz o driver mudar uma linha (ADR-0018 §1).Clique para virar. Recupere a resposta de cabeça antes de conferir.
rejected de failed?err do proposer é um quarto balde?return proposed) com o store intacto. Baldes só existem num passo bem-sucedido.scoreThresholdGate é inclusivo?score >= min. Com o padrão 0.7, 0.70 aprova e 0.69 rejeita.ReviewGate diferente e o reviewAndLearn nem percebe (ADR-0018).Os cinco pontos do lab, em sequência. Use ← → ou os botões.
Os três baldes (resultado normal) versus o caminho err (aborto fail-closed) — cada um pede um conserto diferente.
Três perguntas. A pontuação corre conforme você responde — acerte as três e a revisão fecha.
score 0.95, o gate aprova, mas o MemoryStore está acima do orçamento de chars e recusa a escrita. Onde ela cai?rejected é recusa do gate; failed é "gate sim, store não". Uma escrita aprovada acima do orçamento é gravada em failed com a razão do store, nunca lançada e nunca confundida com recusa de política. O teste "records a store-rejected approved write in `failed`, not as a throw" fixa isso.scoreThresholdGate(0.7). Depois a equipe entrega o Validator real do coda. Quanto do reviewAndLearn muda?ReviewGate" — sem tocar no driver. Esse é o payoff de depender de ports, não de concretos.err(new Error('modelo deu timeout')). O que acontece com o passo e o store?reviewAndLearn faz if (!proposed.ok) return proposed — um erro do proposer faz curto-circuito do passo inteiro antes de qualquer gate ou escrita. Fail-closed é a regra: sem propostas, nenhum aprendizado, não aprendizado parcial.packages/hermes/src/learning/review.test.ts e ache os três casos que você acabou de viver — store-rejected → failed, reinforce-not-duplicate e fail-closed on port errors. A seguir (lição 24): saímos dos labs e seguimos a trilha de ADRs — as decisões (como a ADR-0018 que rege este passo) que explicam por que o motor é assim.