Curso / Lição 23  ·  EN
Lição 23 · Lab · prático 2 de 2

Lab: ligar uma passagem de aprendizado com gate de Validator

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.

Todo o ponto deste lab. Aprender no Alembic é com gate, não auto-aplicar (ADR-0018). O modelo propõe; um Validator dispõe. Ao construir a passagem a partir de fakes você vê precisamente por que uma proposta rejeitada difere de uma gravação que falhou — e por que ambas diferem de um erro que aborta a passagem.

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 (learning/review.ts:30-37):

PortaTipoEm produçãoNeste lab
proposerReviewProposeruma chamada de ModelAdapter, em forma de cintura estreitaum fake retornando propostas fixas
gateReviewGateo Validator do @alembic/codascoreThresholdGate(0.7) (default entregue)
memoryMemoryStoreo store durável persistido em arquivoum MemoryStore real sobre um FsPort fake
proposer summary → propostas gate score ≥ 0.7 ? memory.apply dedup reusado applied[] rejected[] — gate: não failed[] — store: não

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.

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

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.

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);
};
Por que 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".

Passo 3 — rode a passagem e leia os baldes

// 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.

Passo 4 — force um balde 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…'}]
Por que três baldes, não dois

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.

Passo 5 — os caminhos fail-closed

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).

1. Uma proposta marca 0.95, o gate a aprova, mas o MemoryStore está acima do orçamento de chars e rejeita a gravação. Onde ela cai?
Correto: c. 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).
2. Você injeta scoreThresholdGate(0.7). Depois o time entrega o Validator real do coda. Quanto de reviewAndLearn muda?
Correto: b. O gate é uma porta. O default conservador é opt-in por injeção, e "o Validator real do @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.
3. Seu proposer fake retorna err(new Error('model timed out')). O que acontece com a passagem e o store?
Correto: d. 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.

Sua vez — estenda a passagem

Exercício: uma afirmação de "reforce, não duplique"

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:

É 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.

Desafio extra. Escreva um gate que rejeita qualquer proposta cujo 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.