No Lab 1 você construiu um store. Agora você liga a pedra angular da fusão (Lições 4 e 8): uma passagem fechada 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 gate fake 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.
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 (learning/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 |
// 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.
import { MemoryStore, reviewAndLearn, scoreThresholdGate } from '@alembic/hermes'; import { ok, 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
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.
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. Uma falha do modelo em produção vira err(...), que faz a passagem inteira falhar fechada. Envolver o array em ok diz "o revisor rodou com sucesso e aqui está sua saída".// a chamada
Agora monte as três portas e rode uma passagem. O gate default aprova score ≥ 0.7, então a proposta 0.9 aplica e a proposta 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.
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 gravar (ex.: acima do orçamento de chars). Para ver, encolha o orçamento do store para que uma gravação aprovada estoure. A passagem ainda tem sucesso no geral — só aquela gravação cai em failed:
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:'…exceed the limit…'}]
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) fixa exatamente essa separação.
Duas coisas abortam a passagem inteira com err (não um balde): um erro do proposer e um erro do gate. 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 — a passagem inteira falhou fechada; o store fica intocado.
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).
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". Uma gravação aprovada acima do orçamento é registrada em failed com a razão do store, nunca lançada e nunca confundida com uma recusa de política (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". É o retorno de depender de portas, não de concretudes.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 na passagem inteira antes de qualquer gate ou gravação. Fail-closed é a regra: sem propostas, sem aprendizado, não aprendizado parcial.Rode a passagem 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:
applied[] (o gate a aprova das duas vezes).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 é "reinforce, do not duplicate" em review.test.ts.
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.