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

Lab: o passe de aprendizado

No Lab 1 você construiu um store. Agora você liga a pedra angular da fusão (Lições 4 e 8): um passe fechado 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 ReviewGate 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.

Leia primeiro (fonte de verdade)
packages/hermes/src/learning/review.ts — reviewAndLearn (54–70)

Este lab dirige a API real entregue, lida literalmente do repo (rodapé com as linhas exatas). Por que importa pra missão: é o mecanismo pelo qual o Alembic aprende sozinho sem nunca aplicar nada que um Validator não tenha aprovado — o coração de "aprender é com gate, não auto-aplicar" (ADR-0018).

Objetivos desta lição
  • Montar reviewAndLearn a partir das três portas injetadas (ReviewDeps) — sem construir adapter nem store concreto.
  • Rodar um passe e ler os três baldes applied / rejected / failed com suas razões.
  • Forçar cada balde de propósito — e explicar por que failed não é rejected.
  • Distinguir um balde de um err que aborta o passe inteiro (fail-closed).
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 o passe 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 o passe.

01 · Propõe · dispõe · sedimenta — a forma do passe

Antes do código, a forma. Pense num diário de bordo guardado por um editor cauteloso. Ao fim de cada turno, um revisor rabisca o que valeria a pena lembrar (propõe). Um editor lê cada rabisco e decide se entra no diário (dispõe). Só então o que passou é escrito em tinta no diário durável (sedimenta). Nada vira tinta sem passar pelo editor — e essa é exatamente a regra de ADR-0018.

O PASSE EM TRÊS VERBOS · propõe → dispõe → sedimenta (a forma de reviewAndLearn)
1 · PROPÕE proposer: summary → propostas 2 · DISPÕE gate: aprova ou rejeita 3 · SEDIMENTA memory.apply (dedup reusado) O revisor nunca escreve direto no diário. Toda gravação atravessa o editor (gate) — é a forma propõe→dispõe→sedimenta de ADR-0018.

A mesma forma, vista como quem toca o quê: o revisor só escreve em rascunho; o editor é o único portão para a tinta; o diário durável só recebe o que o editor liberou. Essa separação é literal no código — reviewAndLearn chama deps.memory.apply(...) apenas depois de verdict.value.approved ser verdadeiro (review.ts:90-101).

QUEM TOCA O QUÊ · o rascunho é livre; a tinta durável só passa pelo editor (review.ts:90-101)
RASCUNHO o revisor anota livre (propostas + score) passa pelo… EDITOR (gate) única porta para a tinta só se aprovar DIÁRIO EM TINTA MemoryStore durável (persiste no arquivo) ✗ não existe atalho do rascunho direto para a tinta — essa é a invariante de ADR-0018

02 · 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, e todos os três são obrigatórios (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
DIAGRAMA DE LIGAÇÃO · as 3 portas de ReviewDeps entram no driver reviewAndLearn
proposer ReviewProposer (porta) gate ReviewGate (porta) memory MemoryStore (porta) reviewAndLearn (summary, deps) → Result LearnOutcome applied/rejected/failed O driver não constrói nenhuma das três — elas chegam prontas pela injeção. Trocar qualquer porta não muda uma linha de reviewAndLearn (ADR-0009).
FAN-OUT · um turno entra; cada proposta sai num dos três baldes (o destino de cada uma)
proposer summary → propostas gate score ≥ 0.7 ? memory.apply dedup reusado applied[] rejected[] — gate: não failed[] — store: não Verde: gate sim + store sim. Clay: o gate recusou (volta antes do store). Areia: o gate aprovou mas o store recusou. Três destinos, nunca confundidos (review.ts:91-100).

03 · 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 vivo.

import { MemoryStore, reviewAndLearn, scoreThresholdGate } from '@alembic/hermes';
import { ok, err, 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
CICLO DE VIDA DO STORE · construir → load() (uma vez) → apply() (n vezes) — a ordem importa
new MemoryStore(fs) objeto, estado vazio await load() lê o arquivo — 1× (ou inicializa vazio) await apply(...) grava sobre o estado vivo · n vezes repete ⚠ apply() antes de load() = gravar sobre estado não inicializado É o mesmo store da Lição 07. Aqui ele entra como a terceira porta de ReviewDeps — o passe nunca o constrói, só o recebe pronto e carregado.
Por que load() primeiro? O MemoryStore mantém um estado em memória que espelha o arquivo. load() lê o que já existe (ou inicializa vazio) antes de qualquer apply() — sem ele você gravaria sobre um estado não inicializado. É o mesmo store da Lição 07; aqui ele é só a terceira porta do passe.

04 · 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 (types.ts:62-71).

ANATOMIA DE UMA ReviewProposal · os quatro campos e quem lê cada um (types.ts:62-71)
target 'memory' | 'user' op add | replace | remove rationale string (por quê) score number ∈ [0,1] ↓ lido pelo store ↓ lido pelo store ↓ só observabilidade ↓ lido pelo gate O gate só olha o score. O store só olha target+op. O rationale existe para o operador entender a decisão depois — nunca decide nada. Separação de responsabilidades dentro de um único objeto: cada consumidor lê só o campo que lhe diz respeito.
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 (types.ts:118-120). Uma falha do modelo em produção vira err(...), que faz o passe inteiro falhar fechado. Envolver o array em ok diz "o revisor rodou com sucesso e aqui está sua saída".
Preveja antes de continuar
O fakeProposer acima devolve duas propostas: uma com score 0.9 e outra com score 0.4, sob o gate default (piso 0.7). Depois de um passe, quantos itens caem em applied[], quantos em rejected[] e o que sobra em memory.entries('memory')? Chute antes de revelar.
applied = 1, rejected = 1, failed = 0. A 0.9 ≥ 0.7 aprova e sedimenta; a 0.4 < 0.7 é rejeitada antes de tocar o store. Logo memory.entries('memory') tem exatamente ['Build runs offline by default'] — a rejeitada nunca foi gravada. Se você chutou "as duas aplicam", lembre: o piso é a defesa de "aprenda só de vitórias validadas" (gate.ts:24-36).

05 · Passo 3 — rode o passe e leia os baldes

// a chamada

Agora monte as três portas e rode um passe. O gate default aprova score ≥ 0.7 (a fronteira é inclusiva, gate.ts:30), então a proposta 0.9 aplica e a 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 DESFECHO DAS DUAS PROPOSTAS · 0.9 aplica, 0.4 rejeita — e o store fica com 1 entrada
proposta · score 0.9 'Build runs offline by default' proposta · score 0.4 'maybe prefer tabs?' ≥ 0.7 ✓ < 0.7 ✗ applied = 1 rejected = 1 MemoryStore entries('memory').length = 1 ['Build runs offline by default'] a rejeitada nunca chega ao store failed = 0 (nenhuma gravação aprovada foi recusada pelo store). Veredito completo: applied 1 · rejected 1 · failed 0 (review.ts:69).

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 (gate.ts:32-33).

A fronteira é inclusiva — e o teste prova
O gate aprova score === min: o teste entregue "rejects 0.69 and approves 0.70 at the default 0.7 floor" (review.test.ts:182) fixa exatamente isso. Uma proposta de 0.70 exatamente passa; 0.69 não. Detalhe pequeno, consequência grande: a fronteira do que o agente aprende é determinística e testada.
A RETA DO SCORE [0,1] · onde o piso 0.7 corta entre rejeitar e aprovar (gate.ts:30)
0.0 1.0 piso 0.7 REJEITA · score < 0.7 APROVA · score ≥ 0.7 0.69 → rejected 0.70 → applied A fronteira é inclusiva: 0.70 exatamente já aprova (score >= min). Um centésimo a menos, e a proposta vira aprendizado descartado.

06 · Passo 4 — force cada balde, na mão (worked → agora você)

Você viu o passe rodar feliz. Agora force cada um dos três destinos de propósito — é a melhor forma de entender a diferença entre eles. Siga os passos; o último exercício é seu.

Os três baldes não são aleatórios: cada um é o cruzamento de duas decisões binárias — o gate olha o score, e (só se aprovar) o store olha o tamanho. Esta matriz mostra os quatro quadrantes e em que balde cada combinação cai:

MATRIZ DE DECISÃO · score (gate) × cabe no orçamento (store) → o balde de destino
decisão do GATE (score) → decisão do STORE (orçamento) → score < 0.7 (rejeita) score ≥ 0.7 (aprova) cabe estoura rejected[] o gate recusou — o store nem chega a ser consultado applied[] gate sim + store sim → gravado e deduplicado rejected[] ainda rejected — o tamanho é irrelevante se o gate já disse não failed[] gate sim, store não → acima do orçamento A coluna esquerda é toda rejected: o store é consultado depois do gate aprovar (review.ts:90-96). Três baldes saem de quatro quadrantes.
Exemplo resolvido · provoque applied, depois rejected, depois failed
1
Force applied. Mande uma proposta com score 0.9 e conteúdo curto sob o store normal. 0.9 ≥ 0.7 → o gate aprova; o store grava. Cai em applied[].
2
Force rejected. Baixe o score para 0.4. 0.4 < 0.7 → o gate recusa e a proposta volta antes de tocar o store. Cai em rejected[] com a razão do gate (review.ts:91-94).
3
Force failed. Mantenha score 0.95 (o gate aprova) mas encolha o orçamento do store. A gravação estoura o limite de chars; o store devolve err. Cai em failed[] — e o passe continua ok no geral (review.ts:96-100).
4
O código do failed: encolha o memoryCharLimit e mande um conteúdo maior que ele.
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:'…would exceed the limit…'}]
Agora você: sem mudar o store (orçamento normal), o que acontece com uma proposta de score 0.70 exatamente? E com 0.69? Responda antes de revelar.
0.70applied (a fronteira é inclusiva: score >= min, gate.ts:30). 0.69rejected (fica abaixo do piso). É exatamente o teste "rejects 0.69 and approves 0.70". Dica: o procedimento é sempre o mesmo — o score escolhe entre applied e rejected; o orçamento do store escolhe entre applied e failed.

07 · Simulador: force cada balde ao vivo

Agora torne tátil o que você fez na mão. Mexa os três controles — o score da proposta, o tamanho do conteúdo e o orçamento do MemoryStore — e o passe é avaliado exatamente como reviewAndLearn faz: o gate compara score com o piso 0.7; se aprovar, o store compara o tamanho com o orçamento. O balde de destino acende.

proposta score 0.90 gate score ≥ 0.7 ? store cabe no limite ? applied[] rejected[] failed[]

O que o simulador não inventa. Ele replica exatamente a lógica real: score >= 0.7 no gate (gate.ts:30) e a checagem de orçamento no store (memory-store.ts — a gravação "would exceed the limit"). Nenhum número aqui é fabricado; é a mesma decisão que reviewAndLearn toma, só visível.

08 · Por que failed não é rejected

A distinção sutil que o simulador deixa óbvia: 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). Colapsar um no outro mentiria para o operador.

Repare onde cada desfecho se separa na mesma linha do tempo de uma proposta: rejected sai cedo (no gate); failed só se decide um passo depois (no store); e applied percorre os dois. É a ordem exata de processOne (review.ts:90-101):

LINHA DO TEMPO DE UMA PROPOSTA · onde cada balde se separa (review.ts:90-101)
proposta validada t1 · GATE score ≥ 0.7 ? t2 · STORE apply() cabe ? não rejected[] sai em t1 — store nem roda não failed[] só se decide em t2 sim+sim applied[] percorreu t1 e t2 Mesma trilha, pontos de saída diferentes: por isso "não gravou" não é uma resposta — quando não gravou é o diagnóstico.
DOIS CAMINHOS QUE NÃO GRAVAM · mas exigem correções opostas (rejected vs failed)
rejected[] causa: o GATE recusou (score abaixo do piso) correção: relaxar o gate ou melhorar a proposta failed[] causa: o STORE recusou (acima do orçamento) correção: consolidar memória o gate estava certo Mesmo sintoma ("não gravou"), diagnósticos opostos. Por isso são dois baldes, não um (review.test.ts:321).
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:321) fixa exatamente essa separação.

09 · Passo 5 — os caminhos fail-closed

Há um terceiro tipo de desfecho, acima dos baldes: o passe inteiro abortar com err. Duas coisas o causam — um erro do proposer e um erro do gate. Não são baldes: são curto-circuito. 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 — o passe inteiro falhou fechado; o store fica intocado.

No código, isso é uma linha: if (!proposed.ok) return proposed (review.ts:61) — um erro do proposer faz short-circuit antes de qualquer gate ou gravação. O mesmo vale para o gate: if (!verdict.ok) return verdict (review.ts:90). 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).

FLUXOGRAMA · uma proposta atravessa o passe — os desvios e onde cada um pousa
reviewAndLearn(summary) summary vazio ou 0 propostas? SIM ok(no-op) 3 baldes vazios NÃO proposer ou gate retornou err? SIM err — aborta store intocado (61, 90) NÃO gate aprovou (score ≥ 0.7)? NÃO → rejected[] com a razão do gate SIM store gravou? NÃO failed[] passe segue ok SIM ↑ applied[] gate sim + store sim

10 · rejected vs failed vs err — lado a lado

Toda a lição gira em torno de três desfechos que parecem "não gravou" mas são fundamentalmente diferentes. Veja-os explícitos, na mesma tabela: o que os causa, onde a proposta pousa, e o que acontece com o resto do passe.

DesfechoCausaOnde pousaO passe continua?Linha-fonte
appliedgate sim + store simapplied[]sim, okreview.ts:101
rejectedo gate recusou (score baixo)rejected[] com razãosim, okreview.ts:91-94
failedgate sim, store não (orçamento)failed[] com razãosim, okreview.ts:96-100
errproposer ou gate retornou errnenhum balde — abortanão, errreview.ts:61, 90

A coluna que mais separa os quatro desfechos é a última: o passe continua? Três deles são apenas baldes dentro de um ok — o passe segue inteiro. Só o err é diferente em espécie: ele é o Result externo virando { ok: false } e curto-circuitando tudo. Veja a fronteira:

DENTRO DE ok VS UM err EXTERNO · três baldes seguem o passe; err o aborta (review.ts:61,90)
ok(LearnOutcome) — o passe segue inteiro applied[] gravado no store rejected[] gate disse não failed[] store disse não vs err(Error) Result externo não-ok aborta · store intocado Os três baldes são conteúdo de um sucesso; o err é a ausência de sucesso. Nunca confunda um balde cheio com um passe que falhou.
Em linguagem simples: pense num editor com um diário de páginas limitadas. applied = o editor aprovou e havia espaço, virou tinta. rejected = o editor disse "não vale a pena" (e anotou o porquê). failed = o editor aprovou, mas a página acabou — não é culpa da ideia, é falta de espaço. err = o próprio revisor ou o editor passou mal e o expediente foi cancelado; nada no diário muda.
Na precisão técnica: applied/rejected/failed são os três campos de LearnOutcome (types.ts:99-106), sempre dentro de um ok(...). err é o Result externo virar { ok: false }: reviewAndLearn faz return proposed / return verdict quando uma porta falha (review.ts:61, 90), sem nunca lançar. Fail-closed por contrato: o módulo nunca lança através da fronteira (review.ts docstring).

11 · Como isso se encaixa

Você acabou de construir reviewAndLearn ligando suas três portas à mão. Afaste a câmera: o que você montou neste lab é o loop fechado em miniatura. As mesmas três portas — proposergatememory — são o último elo da esteira real do motor: a run de hoje só deixa marca durável para a run de amanhã passando por aqui, e só depois que todos os gates passaram. No lab elas são fakes; em produção, o proposer é um ModelAdapter, o gate é o Validador do @alembic/coda e o memory é o store persistido no run-dir. A forma que você ligou não muda — é a invariante de injeção (ADR-0009) que torna o seu passe de brinquedo idêntico, em estrutura, ao de produção.

FLUXO · o passe que você montou é o último elo do loop fechado (clique “Passo a passo” para acender, um elo por vez)
run (unidades)o turno executa Proof + Validadorgates passam (Lição 17) só se --learn ESTE LAB · as 3 portas que você ligou em reviewAndLearn proposerpropõe gatedispõe memorysedimenta applied / rejected / failed — os três baldes que você forçou na seção 06 proposer ou gate quebra ⇒ falha fechado, memória intacta (seção 09) MemoryStore (Lição 07)MEMORY.md + USER.md no run-dir fecha o loop: vira contexto do próximo turno
Clique “Passo a passo” para acender o caminho da run até o MemoryStore — exatamente o passe que você montou neste lab.

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 passe que você reconstruiu neste lab é 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 e clicar nesta etapa no mapa, abra o mapa interativo da metodologia.

CONECTA COM · as quatro lições vizinhas deste lab
Lab: o passe de aprendizado Lição 23 (você está aqui) Lição 08 · teoria Lição 07 · memory Lição 04 · loop Lição 22 · outro lab

12 · Na prática

Você montou o passe das três portas e forçou cada balde na mão. Agora rode o mesmo passe pela CLI real, ponta a ponta — é o mesmo reviewAndLearn que você acabou de construir, só que dirigido pela alembic run em vez de um teste. O aprendizado é opt-in: uma run normal não aprende. A flag --learn liga o passe gated (é real — apps/cli/src/args.ts:189, default OFF, ADR-0018), e --offline roda tudo hermético ($0, sem rede). Nesse modo o proposer é o adapter offline determinístico e o gate é o Validador — exatamente as portas do lab, agora de verdade:

# 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
De onde sai essa linha. É o deps.write no fim do Learning Gate em apps/cli/src/commands.ts:1386: imprime literalmente learning gate: ${applied} applied, ${rejected} rejected. Os dois números vêm direto dos baldes do LearnOutcome — os mesmos applied e rejected que você forçou nas seções 05–07. O balde failed não entra nessa linha-resumo: ele fica no log estruturado run:learning-pass-done (com applied/rejected/failed, commands.ts:1379). No offline o resultado é determinístico — os contadores exatos dependem do que o proposer offline gera para o seu GOAL.md; o 2 applied, 1 rejected acima é ilustrativo.

Dois detalhes que amarram direto com o que você construiu. Primeiro: sem a flag, nada acontece — o aprendizado é aditivo e desligado por padrão, então uma run verde continua verde (commands.ts:1364, if (args.learn)). Segundo: se não houver adapter de proposer disponível, o passe é pulado (log run:learning-pass-skipped, commands.ts:1367), nunca quebra a run — é a mesma postura fail-closed da seção 09 aplicada ao próprio gatilho.

Experimente · rode o passe gated e force cada balde na CLI real
1
Entre no repositório e garanta o build. cd alembic e então pnpm -r typecheck && pnpm -r build && pnpm -w test — o baseline tem que estar verde antes de qualquer run.
2
Tenha um 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.
3
Rode com o passe gated, hermético. 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 que você montou.
4
Leia a última linha. Procure learning gate: N applied, N rejected na saída. applied = aprovadas pelo gate e escritas (a coluna verde da sua matriz da seção 06); rejected = o gate disse não (a coluna esquerda). Se aparecer run:learning-pass-skipped, faltou um proposer — não é um erro da run.
Agora você — force cada balde de verdade: abra o run-dir e veja a memória sedimentada — as escritas applied caem em MEMORY.md e USER.md 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 (reforçar, não duplicar), então o store não cresce — é a sua afirmação de “Sua vez” provada pela CLI. Para inspecionar o balde failed, leia o log estruturado (run:learning-pass-done) — onde a sua matriz do quadrante “gate sim, store não” aparece num run real. [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 que você reconstruiu também é exercitado pelos testes do kernel — pnpm --filter @alembic/hermes test roda os casos do review.test.ts (a fronteira 0.69/0.70, failed vs rejected, fail-closed nos erros de proposer/gate) que este lab destilou. É a prova de que o comportamento que você montou na mão é 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.

As 3 portas
Quais são as três ReviewDeps?
clique pra virar ↻
Resposta
proposer (ReviewProposer), gate (ReviewGate), memory (MemoryStore). Todas injetadas, todas obrigatórias (review.ts:30-37).
Piso do gate
Qual score o scoreThresholdGate() default aprova?
clique pra virar ↻
Resposta
score ≥ 0.7, inclusivo. 0.70 aprova, 0.69 rejeita ("aprenda só de vitórias validadas", gate.ts:24-36).
failed vs rejected
Qual a diferença entre failed e rejected?
clique pra virar ↻
Resposta
rejected = o gate disse não. failed = o gate disse sim mas o store recusou (acima do orçamento). Correções opostas (review.ts:91-100).
Fail-closed
O que vira err e aborta o passe?
clique pra virar ↻
Resposta
Um erro do proposer ou do gate: return proposed/verdict (review.ts:61, 90). O store fica intocado — nunca aprendizado pela metade.

Sua vez — estenda o passe

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

Rode o passe 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:

  • Ambos os passes reportam a proposta em applied[] (o gate a aprova das duas vezes).
  • Mas 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 é "re-proposing an existing entry does not create a second entry" em review.test.ts:218-219.

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 (types.ts:122-130).

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.

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". a colapsa os dois baldes — mas a causa (store cheio) e a correção (consolidar memória) são opostas à de uma recusa do gate. b confunde balde com fail-closed: só um erro do proposer/gate vira err; uma rejeição do store é registrada, não lançada. d viola a regra de "nenhum descarte silencioso" — a gravação cai em failed com a razão do store (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" (gate.ts docstring). a joga fora a injeção — depender de uma concretude é exatamente o que ADR-0009 proíbe. c inventa um quarto balde: o contrato de saída (LearnOutcome) não muda com o gate. d acopla portas independentes: trocar o gate não toca o proposer.
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 antes de qualquer gate ou gravação. a inventa "propostas até então": o erro vem do proposer, então não há proposta alguma para aplicar. b confunde fail-closed com balde — failed é só para gravações que o store recusou, não para erros de porta. c contradiz o contrato: o módulo nunca lança através da fronteira, só devolve err.
4. Você roda o passe duas vezes com a MESMA proposta (score 0.9) contra a mesma instância de store. Qual o resultado?
Correto: a. O loop não tem dedup próprio — gravações aprovadas fluem pelo dedup do store ("reforce, não duplique", Lição 8). O gate aprova das duas vezes (logo applied nos dois), mas a segunda gravação é um sucesso no-op no store, então só há 1 entrada. b ignora o dedup do store. c erra a camada: o gate só olha o score, não duplicidade — quem deduplica é o store. d inventa um "conflito": o dedup é um sucesso silencioso (ok), não um failed. O teste "re-proposing an existing entry does not create a second entry" (review.test.ts:218) prova exatamente isso.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que o store deduplica e o loop não?", "Como o Validator do coda vira um ReviewGate?", "O que conta no orçamento de chars do MemoryStore?". É só dizer.