Pular para o conteúdo
Fantástico Mundo de Jon
RSS

Escrever o caso de borda sobe o acerto de 46% para 90%

360 gerações, três modelos locais, três formas do mesmo pedido. O salto grande está entre não escrever o caso de borda e escrever: 46% para 90%. A tabela em markdown, contra o mesmo texto corrido, não pagou o que eu esperava.

Com pressa? Peça o TL;DR ao Claude — ele lê a página e resume.

Em julho eu defendi que trocar prompt em prosa por arquivo de spec fazia o agente acertar mais. O post está aqui: contrato em tabela, casos de borda, fora de escopo, critério de aceite, e uma tabela de resultados com as células em branco e a promessa de preencher depois.

Em outubro eu preenchi. A tese de julho, do jeito que eu tinha escrito, não segurou. A estrutura do arquivo não foi o que moveu o ponteiro.

O que moveu foi outra coisa, e essa sobrou mais forte do que a tese original. Entre um pedido de uma frase e a mesma tarefa com os casos de borda escritos em algum lugar, o acerto em borda foi de 46% para 90%. Quase o dobro. Entre a spec arrumada em markdown e o mesmo conteúdo jogado em parágrafo corrido, a diferença foi de 3,6 pontos — pequena, estável entre repetições, e a favor do texto corrido.

Ou seja: o formato em que você arruma o pedido importa pouco para o modelo. Ter decidido e escrito o que acontece no caso estranho importa muito. É isso que eu levo deste experimento, e é isso que muda o que eu faço amanhã de manhã quando abro o Aider.

O tamanho do tombo da tese estrutural, para ser específico: na corrida sem aleatoriedade, o braço com a spec e o braço com exatamente a mesma informação em parágrafo deram resultado idêntico em 18 das 30 células. Na tarefa slugificar, com o devstral:24b a temperatura 0, os dois voltaram com o mesmo código. Não era parecido: era byte a byte o mesmo arquivo.

Bancada · outubro de 2025

Placa
RTX 5090 · 32 GB
Modelos
qwen3-coder:30b · devstral:24b · qwen2.5-coder:32b
Quantização
Q4 via Ollama
Acesso
API crua, um turno, sem histórico
Tarefas
10 funções puras pequenas em JS
Braços
prosa · prosa longa · spec
Gerações
360 (90 + 270)
VRAM ocupada
20 · 17 · 24 GB, na ordem dos modelos
Tempo das 2 corridas
15,2 min somados de chamada

Por que são três braços, e não dois

Comparar “uma frase” com “spec de sessenta linhas” não responde nada. Claro que mais informação ajuda. Isso é obviedade com cara de resultado, e era o erro que eu mais queria evitar.

Se eu só comparasse prompt curto com spec, eu não saberia separar duas coisas diferentes: o fato de a informação existir e o fato de ela estar em tabela com cabeçalho. A primeira é conteúdo. A segunda é forma. Em julho eu estava vendendo a segunda. Precisava medir as duas.

Por isso a bancada tem três braços, e o do meio é o que dá sentido aos outros dois:

  • prosa — pedido de uma frase, só caminho feliz, nenhum caso de borda mencionado.
  • prosa longa — exatamente a mesma informação da spec, em texto corrido, sem estrutura.
  • spec — a mesma informação em markdown estruturado, com os quatro blocos de sempre.

Assim prosa contra prosa longa mede informação, e prosa longa contra spec mede forma. A segunda comparação era a tese de julho. Não segurou. A primeira é o achado que sobrou, e é o que justifica o resto deste post.

O resto do desenho existe para fechar as saídas fáceis. Os testes de cada tarefa são separados em caminho feliz e casos de borda, e cada caso de borda corresponde a um item que a spec declara e a prosa curta omite, um para um. A instrução de formato de saída é igual nos três braços, senão eu estaria medindo formato junto com conteúdo. Cada tarefa traz uma implementação de referência que precisa passar em 100% dos próprios testes antes de a corrida começar: se a referência falha, o errado é o teste, não o modelo. E o código gerado roda em processo isolado com timeout de 10 s, porque código de modelo não merece confiança. Ele entra em loop, chama process.exit, estoura a memória.

O harness inteiro está no repositório. scripts/bancada-spec.mjs roda a corrida, scripts/bancada-spec/tarefas.mjs guarda as dez tarefas com os três prompts e os testes de cada uma, e scripts/bancada-spec/executor.mjs roda um candidato em processo próprio.

pnpm bancada --modelos qwen3-coder:30b,devstral:24b,qwen2.5-coder:32b --amostras 3 --temperatura 0.7

referencias validadas: 10 tarefas, todas em 100% bancada: 3 modelos × 10 tarefas × 3 braços, temperatura 0.7

a corrida de variância; sem --amostras e --temperatura, roda determinística

O --retomar aproveita o que já rodou. Você vai precisar dele, porque 270 gerações não cabem numa sessão sem alguma coisa cair no meio.

Primeira corrida: temperatura 0, 90 gerações

Um pedido por célula, sem aleatoriedade nenhuma. É a corrida que responde “com este prompt exato, o que sai”.

Braço Caminho feliz Casos de borda % borda Tarefas 100%
prosa 78/116 93/200 47% 0/30
prosa longa 116/120 185/207 89% 18/30
spec 110/120 183/207 88% 14/30
RTX 5090 · 3 modelos locais em Q4 · temperatura 0 · uma geração por célula · 90 gerações. Testes em processo isolado, timeout de 10 s. Os totais de teste diferem entre braços porque geração que não devolve módulo válido não contabiliza teste nenhum — e isso aconteceu mais na prosa curta.

A primeira coluna é a que eu já esperava, e não é notícia: caminho feliz o modelo acerta quase sempre, em qualquer braço. Todo mundo entende “formata um número em reais”.

A coluna de borda é onde a história acontece de verdade. 47% contra 89%. É o mesmo fenômeno que eu descrevi em julho, agora com número em cima. O modelo não erra por burrice. Erra porque fechou sozinho a lacuna que o pedido deixou aberta. Quando ninguém diz o que fazer com null, com negativo, com string vazia, ele inventa uma resposta plausível — e plausível reprova no teste.

E aí vem a terceira linha, que é onde a minha tese de julho morre sem drama. A spec estruturada ficou um ponto abaixo da mesma informação jogada em parágrafo. Um ponto de diferença eu não reivindico. Mas eu precisava que a spec ficasse na frente, e ela não ficou.

Leia de novo a comparação que importa: prosa curta contra qualquer um dos dois braços informados. Quase o dobro de acerto em borda. É isso que paga o custo de sentar e escrever o comportamento antes de pedir o código.

Segunda corrida: 270 gerações a temperatura 0,7

Uma corrida determinística não distingue diferença real de sorte. Repeti três vezes a 0,7, que é onde essas ferramentas costumam rodar de verdade.

Braço Casos de borda % borda Por repetição
prosa 277/607 46% 46%, 48%, 43%
prosa longa 554/614 90% 88,0%, 91,3%, 91,3%
spec 538/621 87% 87,4%, 86,5%, 86,0%
RTX 5090 · 3 modelos locais em Q4 · temperatura 0,7 · 3 repetições · 270 gerações. A coluna da direita é a taxa de cada repetição isolada, para dar ideia da dispersão.

A dispersão importa mais que a média aqui. A prosa curta oscila cinco pontos entre repetições, enquanto a spec oscila pouco mais de um. E as três repetições da spec ficaram abaixo da pior repetição da prosa longa, o que quer dizer que os 3,6 pontos de diferença não saíram de uma rodada azarada. Também não fazem veredito, e a nota mais abaixo explica por quê.

O que se repete, estável, nas três rodadas, é o abismo entre não escrever a borda e escrever. 46% de um lado. 90% e 87% do outro. Se você só puder levar um número deste post, leve esse.

Quebrando por modelo, na mesma corrida:

Modelo prosa prosa longa spec
qwen3-coder:30b 48% 95% 95%
devstral:24b 39% 92% 85%
qwen2.5-coder:32b 49% 84% 81%
Percentual de casos de borda por modelo na corrida de temperatura 0,7. O devstral:24b teve duas gerações sem módulo válido no braço de prosa, então ali a base é 193 testes em vez de 207.

O modelo mais forte empata os dois braços informados em 95%. Nos outros dois, a prosa longa fica na frente por sete e por três pontos. Nenhum dos três colocou a spec na frente. A tabela agrega as três repetições, então ela não prova nada sobre repetição isolada — a estabilidade quem mostra é a coluna da direita da tabela anterior.

Em todo modelo, o salto grande continua sendo o mesmo: sair da prosa curta. O devstral:24b vai de 39% para 92% só porque alguém escreveu o que a função faz nos cantos. Não porque alguém abriu um arquivo com ## Casos de borda em markdown.

Onde a média esconde o que aconteceu

Média é uma péssima ferramenta para essa pergunta. Olhando célula por célula na corrida determinística, que são 3 modelos vezes 10 tarefas, spec e prosa longa deram resultado idêntico em 18 das 30 células. Nas 12 que sobraram, a prosa longa ganhou 8 e a spec ganhou 4.

Fui abrir uma das idênticas na mão, porque eu não acreditei. Tarefa slugificar, devstral:24b, temperatura 0: os dois braços produziram código byte a byte igual. Dois prompts de tamanho e diagramação completamente diferentes, carregando o mesmo conteúdo, colapsando na mesma saída.

Acontece que, para o modelo, naquele ponto do espaço de decodificação, tabela e parágrafo eram o mesmo pedido. A informação era uma só. A forma evaporou.

formatarBRL: zero em 11 testes, nos três modelos, por um caractere

O achado mais interessante da corrida é qualitativo e não aparece em média nenhuma. É também o melhor exemplo do que “escrever a borda” quer dizer na prática.

Na tarefa formatarBRL, o braço prosa tirou 0 de 11 nos três modelos. Zero absoluto, inclusive no caminho feliz. E a causa é a mesma nos três:

Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(1234.56);
// 'R$ 1.234,56' — visualmente correto, e reprova em qualquer comparação de string

// o caractere entre "R$" e "1" não é espaço:
// código 160 (U+00A0, espaço inquebrável), não 32

O modelo escreveu a solução idiomática, aquela que qualquer revisor humano aprovaria numa passada de olho. E ela reprova em assert.equal contra 'R$ 1.234,56' com espaço comum, porque o Intl devolve espaço inquebrável.

Quando a informação está escrita, e aqui ela cabe numa linha (“prefixo R$ seguido de um único espaço comum, não use espaço não separável”), dois dos três modelos passam a tratar o caso: ou chamam Intl e trocam o caractere, ou formatam na mão. O qwen2.5-coder:32b continua chamando Intl sem tratamento e falha nos três braços, com a spec na frente dele e tudo.

Esse é exatamente o “erra plausível” que eu descrevi em julho, agora no nível do caractere. Não é código absurdo, que eu pegaria em dez segundos de revisão. É a linha que todo mundo escreveria, decidindo em silêncio uma coisa que o pedido não decidiu, e que só vai aparecer quando o valor cair num CSV ou numa comparação de string do outro lado do sistema.

Se tem um gesto concreto que este post recomenda, é este: antes de pedir a função, escreva a linha que o formatarBRL precisava. Uma frase. O caractere entre o R$ e o número. O que fazer com null. O que acontece na última página. Não precisa de tabela nenhuma para isso. Precisa da decisão existir em algum lugar que o modelo vá ler.

O que a spec custa em token e em tempo

Na tarefa truncar, o prompt em prosa curta gastou 94 tokens de entrada. A mesma tarefa em spec gastou 556, algo como seis vezes mais, para uma diferença de acerto que, contra a prosa longa, não apareceu.

Com modelo local esses 462 tokens a mais são tempo de prefill, e é pouco: 4 ms por geração nesta placa, mediana de 141 ms contra 145 ms, cinco medições de cada. Com modelo de API eles viram conta no fim do mês, multiplicada por toda chamada em que a spec entra no contexto.

O custo é o mesmo nos dois braços informados, porque a informação é a mesma nos dois. O que a medição diz é que esse custo compra o salto de 46% para 90%, e não compra mais nada pelo fato de estar arrumado em tabela.

No fim das contas, a conta que importa não é “spec versus prosa longa”. É “pedido de uma frase versus eu sentei e decidi os cantos”. O segundo custa tokens. Também custa o tempo de você pensar. E é o único custo, dos que eu medi, que voltou com retorno claro.

Por que a bancada não passou pelo Aider

Na prática eu não converso com o modelo por fetch. Dirijo o modelo local pelo Aider:

# direto no Ollama
aider --model ollama_chat/qwen2.5-coder:32b --read docs/specs/truncar.md src/texto.js

# ou por um gateway compatível com OpenAI
export OPENAI_API_BASE=http://localhost:4000/v1
aider --model openai/qwen2.5-coder:32b --read docs/specs/truncar.md src/texto.js

O --read continua sendo o detalhe que mais importa. Ele carrega a spec no contexto sem torná-la editável. Agente que pode editar a spec “resolve” divergência reescrevendo a referência, e aí eu perco a única coisa que servia de parâmetro.

Isso continua valendo mesmo depois desta bancada. O arquivo não ganhou acerto por geração por ser estruturado. Ganhou utilidade por ser um artefato que eu controlo, que não some com a sessão, e que o agente não pode reescrever para se livrar do problema.

E é justamente por isso que essa bancada não mediu através do Aider. O Aider acrescenta repo map, formato de edição e retentativa. Medindo por ele, o número seria do Aider e não do modelo. A bancada fala com o modelo por API crua, um turno, sem histórico. Ela responde “o modelo entendeu o requisito?”, que não é a mesma pergunta que “a ferramenta terminou a tarefa?”.

Agora, onde os modelos pequenos quebram de verdade no uso diário em 2025 não é escrever a função. É aplicar o patch no formato de busca-e-substituição que o Aider espera. O bloco volta com a indentação trocada, ou com um trecho de contexto que não bate byte a byte com o arquivo, e a edição falha. O código estava certo o tempo todo. Isso não aparece em taxa de acerto de função nenhuma, e é a maior parte do atrito real de quem usa isso todo dia.

Onde essa medição não vale

  • Dez funções puras pequenas não são tarefa de repositório real. Não há dependência entre arquivos, convenção do projeto, código legado para respeitar. A seção “fora de escopo”, que eu chamei em julho de a que mais paga, quase não tem o que fazer aqui: não existe vizinho para o modelo refatorar de passagem. É bem possível que a estrutura pague justamente onde este experimento não olha.
  • Um turno só, sem laço de agente. Sem teste rodando, sem erro voltando, sem correção. Boa parte do valor de um critério de aceite executável está exatamente no laço que esta bancada não tem.
  • Modelos locais de 24 a 32B. Nada garante que o resultado se transporta para modelo grande de API, nem para modelo menor que estes.
  • Comparação de string é frágil, e a formatarBRL prova. Um caractere invisível zerou uma tarefa inteira. Onde meu teste é rígido demais, reprovo código que serviria; onde é frouxo, aprovo código que não serve. Os 3,6 pontos entre spec e prosa longa são pequenos o suficiente para caber nessa fragilidade.
  • Três repetições dão ideia de dispersão, não intervalo de confiança. Não fiz teste estatístico nenhum, e não vou fingir que fiz.

Leitura honesta: o que eu medi com força é o efeito de escrever versus não escrever, em função pura, em modelo local, em um turno. O que eu não medi é se a tabela ajuda o humano a não esquecer o caso, se o arquivo ajuda no laço com teste falhando, ou se em repositório de verdade a seção de fora de escopo segura o modelo. Essas perguntas ficam em aberto. Não vou empurrar o resultado para onde ele não foi.

O que eu mudei de ideia, e o que fazer com isso amanhã

Escrevi em julho que a spec fazia o agente acertar mais. Do jeito que escrevi, está errado. O que faz o modelo acertar mais é eu ter decidido e escrito o que acontece quando o valor é negativo, quando a entrada é null, quando a página passa do fim. A spec é o lugar onde eu costumo fazer isso, e não o mecanismo que produz o resultado.

Do post anterior continua de pé a parte que interessa: a seção de casos de borda é o que carrega tudo. Foi lá que os 44 pontos apareceram. O que cai é a ideia de que a tabela, os cabeçalhos e o markdown fazem diferença para o modelo. Para o modelo, pelo que eu medi, não fazem.

Então a pergunta útil passa a ser outra. Se a estrutura não compra acerto por geração, para que serve o arquivo? Aqui eu preciso ser claro, porque a fronteira importa: isto é julgamento, não medição. Não testei nada do que vem abaixo.

  • Versionamento. Spec colada no prompt morre com a sessão. No repositório, ela entra no diff e alguém revisa a decisão antes de ela virar comportamento.
  • Revisão contra referência. A pergunta na revisão deixa de ser “isso parece certo?” e vira “isso bate com o arquivo ao lado?”. A segunda tem resposta.
  • Reuso entre ferramentas. O mesmo arquivo serve Aider, Claude Code, Cursor e eu lendo. Prosa colada num chat não serve nem a segunda vez.
  • Sobrevida. O modelo troca a cada poucos meses. O arquivo que diz o que o sistema faz continua valendo depois da troca; o prompt afinado para um modelo específico, não.

A estrutura serve para mim, e para a próxima pessoa que abrir o arquivo. É por isso que eu continuo escrevendo spec. Só não é o que eu disse que era.

Se você quer uma regra prática saída desta bancada, fica assim. Antes de disparar o agente, escreva os casos de borda — em parágrafo, em lista, em tabela, tanto faz para o modelo. O que não pode é deixar a lacuna aberta e torcer. Depois, se o pedido for viver mais que uma sessão, coloque isso num arquivo com --read e versionamento. A primeira parte é o que os 46% contra 90% pagaram. A segunda é o ofício em volta, e eu ainda faço, com menos ilusão sobre o que ela compra em acerto puro.

Se você for repetir, é pnpm bancada, com --modelos, --amostras, --temperatura e --retomar. As tarefas estão em scripts/bancada-spec/tarefas.mjs, com os três prompts lado a lado, e é essa a parte que vale abrir antes de acreditar em qualquer coisa que você acabou de ler. Não acredite em número de post técnico. Nem nos meus. Rode.