# Evals com cache — regressão de prompt e de modelo sem chave no CI

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** eval, evals, response cache, cache de respostas, dataset de eval, regressão de prompt, troca de modelo, model swap, prompt regression, EvalFixture, OfflineChatClient, DiskBasedResponseCacheProvider, nó evals, --refresh-cache
> **Read by Claude in:** implement (quando a feature muda prompt, troca modelo, ou o projeto vai declarar o nó `evals` no `verify`)

**Verified against:** Microsoft.Extensions.AI.Evaluation 10.9.0 + `.Quality` + `.Reporting` (ai-pin 2026-09-08); provado por `templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs`, que compila e roda no CI contra exatamente essas versões. A superfície de `DiskBasedReportingConfiguration`, `DiskBasedResponseCacheProvider`, `ReportingConfiguration` e dos avaliadores de `.Quality` foi **medida por reflexão sobre as assemblies `Microsoft.Extensions.AI.Evaluation*` 10.9.0** em 2026-09-08. Last-verified: 2026-09-08.

---

## Por que eval não é teste unitário

Um teste unitário responde "esta função ainda faz o que fazia?". Um eval responde
"este **produto** ainda responde o que respondia?" — e a diferença não é de grau.

Trocar o prompt de sistema de um agente, ou trocar `gpt-4.1` por `gpt-5`, deixa **build
verde e suíte unitária verde**. Nenhuma das duas coisas lê o comportamento do modelo; as
duas leem o código em volta dele. O prompt e o modelo são a única parte do produto cujo
comportamento não é coberto por nada do que já existe no gate.

O precedente é de campo, e é caro: um repositório real manteve o script de eval **fora do
CI** por decisão escrita (`ci.yml`, decisão registrada D-F5-2) porque *"variância de LLM
real não pertence a pipeline determinístico"*. A decisão estava certa **para aquele
desenho**. Um eval que chama o modelo a cada PR é lento, caro e flaky — e um eval flaky é
um eval desligado, que verifica exatamente nada.

Este standard descreve o desenho em que a decisão muda: **o eval lê de uma cache de
respostas commitada**, e por isso é rápido, gratuito e determinístico.

## O que se ganha, em uma frase

Com a cache quente, a suíte de eval roda **sem credencial e sem rede**: a biblioteca
embrulha o cliente interno num cliente de cache, e no *hit* o cliente interno nunca é
invocado. Trocando o cliente interno por um que **recusa** (`OfflineChatClient`), o único
caminho para a rede fica fechado — ou tudo resolve pela cache, ou a suíte fica vermelha
nomeando o cenário que faltou.

Isso é o que permite o nó de eval existir no CI de um repositório que **não tem uma única
chave de LLM nos seus workflows**. Um mecanismo que exigisse uma chave seria adotado por
ninguém.

## Dataset por agente e por tenant

```
eval-datasets/
├── triagem/acme/a-saudacao.json
├── triagem/acme/b-escalonamento.json
├── triagem/globex/a-saudacao.json
└── orcamento/acme/a-resposta-estruturada.json
```

Os dois primeiros segmentos existem porque a pergunta útil quase nunca é "o produto
regrediu?", e sim **"o agente X regrediu para o tenant Y?"**. Um dataset plano não responde
isso sem convenção de nome, e convenção de nome é a forma mais frágil de estrutura.

O caminho **é** a identidade do cenário: ele vira a chave do cenário na cache. Dois tenants
têm cenários com o mesmo nome de propósito (`saudacao`, `handoff`), e uma chave que os
confundisse devolveria a resposta de um tenant para o outro.

> **Dado real de tenant é proibido no dataset.** A cache guarda **mensagens inteiras** e é
> commitada: um segredo que entre no dataset entra no git, para sempre. O fixture só lê
> cenário do diretório versionado — nunca de banco, nunca de variável de ambiente.

## O que entra na chave de cache

Medido no pacote 10.9.0, e o achado importa:

| Entra na chave | Não entra |
|---|---|
| As mensagens e as `ChatOptions` | **O endpoint (`ProviderUri`)** |
| `ProviderName` do `ChatClientMetadata` | |
| `DefaultModelId` do `ChatClientMetadata` | |
| A versão interna de formato de cache da biblioteca | |

**A documentação afirma que o endpoint entra. Ele não entra.** A divergência entre o
comentário XML da API e o comportamento medido é o que torna possível um cliente offline
sem endpoint algum: ele não tem para onde ligar, e não precisa ter.

A consequência prática: o cliente que **lê** a cache tem de replicar `ProviderName` e
`DefaultModelId` do run que a **gravou**. Se divergirem, a chave computada na leitura é
outra e **todo** cenário dá miss — uma cache quente indistinguível de uma cache vazia.

## TTL explícito, sempre — e por quê

`Defaults.DefaultTimeToLiveForCacheEntries` são **14 dias**, e a expiração é **destrutiva na
leitura**: ao encontrar uma entrada vencida, o provedor **apaga o diretório da chave** antes
de devolver `null`.

Uma cache commitada com o default **se apaga sozinha ao ser lida**, duas semanas depois de
gravada. O sintoma chega como CI verde por duas semanas e então miss em massa num dia em
que ninguém mexeu em nada — com a árvore apagada no working tree, sujando o `git status`.

Um TTL implícito aqui não é preguiça de configuração; é uma bomba-relógio. Declare-o, e
declare-o **longo** (o kit recusa qualquer valor abaixo de um ano, com a razão na mensagem).

## O carimbo, e o que ele responde de graça

A função de hash que produz a chave **não promete ser estável entre versões da biblioteca**
— a própria biblioteca carrega um número de versão de cache interno, justamente para
invalidar tudo quando o formato muda. Ou seja: **subir o pin pode invalidar a árvore
inteira**.

Sem carimbo, isso chega como N misses inexplicáveis. Com carimbo, chega como uma frase:

```
eval-cache-stale-pin: Microsoft.Extensions.AI.Evaluation: gravada 10.9.0 vs pin 10.11.0
```

`.morph-eval-cache.json` fica na raiz da árvore e guarda o `pinnedAt` e as versões da
família `Microsoft.Extensions.AI` sob as quais a árvore foi gravada. **Carimbo ausente tem
motivo próprio**, distinto de carimbo divergente: as duas situações pedem ações diferentes
— a primeira é "regrave", a segunda é "regrave **porque o pacote mudou**".

## O que se commita, e o que não

```
<cachePath>/                          COMMITADO
├── .morph-eval-cache.json            o carimbo
└── cache/<cenário>/<iteração>/<chave-curta>/{entry.json, contents.data}

<cachePath>/../eval-results/          NUNCA COMMITADO
└── results/<execução>/<cenário>/<iteração>.json
```

Os resultados de execução carregam **latência medida por cronômetro, uso de tokens e o flag
de cache hit**: são não-determinísticos por natureza, e commitá-los é diff garantido em toda
rodada, sem sinal nenhum.

Eles ficam fora da árvore commitada **por construção** — outra raiz de armazenamento — e
não por um `.gitignore` dentro de um diretório commitado. Uma regra de ignore ali é uma
regra que a próxima pessoa remove sem entender.

### Por que a chave é CURTA, e por que isso não é detalhe

O provedor de cache do kit (`ShortPathResponseCacheProvider`) nomeia o diretório da entrada
com os **16 primeiros caracteres** do hash, e não com o hash inteiro. A chave completa é
verificada dentro do `entry.json`: se dois cenários colidirem no prefixo, a leitura vira
**miss** — nunca a resposta errada.

O motivo é medido, e custou uma rodada inteira. Com o hash inteiro (96 caracteres em hex), o
pior caminho relativo desta árvore dava **225**. Somado à raiz do repositório nesta máquina
(73), o absoluto dava **298** — contra os 260 do `MAX_PATH` do Windows. O git **não recusou
barulhento**: avisou `Filename too long` no stderr, commitou **1 de 9 arquivos**, e saiu com
código 0.

E o dano real não foi o arquivo faltando. Foi que o canário de CI — um `git diff --exit-code`
sobre a árvore — passou a **reportar limpo**, não porque a árvore estivesse limpa, mas porque
o git nunca enxergou aqueles arquivos. **Um guarda que não consegue observar o objeto reporta
verde por construção.**

Com a chave curta o pior caminho é **145**, e o teto que o cobra é 160 — a diferença é
orçamento explícito, não sobra por acaso:

| Parcela | Chars |
|---|---|
| `MAX_PATH` utilizável do Windows | 259 |
| − `node_modules\@polymorphism-tech\morph-spec\` (estes arquivos viajam no tarball npm) | 43 |
| − teto do caminho relativo | 160 |
| = folga para a raiz do projeto de quem instala | **56** |

Quem cobra os dois fatos — que cada arquivo é **endereçável pelo git** e que o pior caminho
cabe no teto — é `test/framework/eval-cache-portability.test.js`, e ele recusa passar sobre
um conjunto vazio: um canário que roda sobre zero arquivos é exatamente o canário cego que
o defeito original tinha.

### O payload é opaco: `-text` no `.gitattributes`

A árvore de cache precisa de uma linha própria em `.gitattributes`:

```
<cachePath>/** -text
```

Sem ela, o `text=auto eol=lf` do repositório **normaliza** os `contents.data` — medido: avisos
de CRLF→LF nos quatro payloads, e bytes que não sobreviveriam a um clone. `-text` (e não
`binary`) porque ele preserva a fidelidade de byte **sem** perder o diff legível: quando o
canário acusa, ele mostra o quê mudou, e não apenas que mudou.

E o invariante que sustenta a commitabilidade é barato de cobrar: **um run offline não
escreve um byte na árvore de cache.** Um canário que compara a árvore antes e depois (por
conteúdo, não por data de modificação) morde no dia em que a biblioteca passar a tocar disco
no caminho de hit — e não meses depois, quando alguém estranhar um diff que aparece sozinho.

## Gate duro só em propriedade determinística

| Papel | Quem | Reprova sozinho? |
|---|---|---|
| Asserção determinística | contém / não contém, chave de JSON, ferramenta escolhida, argumento de ferramenta, ausência de PII | **SIM** — é o gate |
| Juízo de LLM | `RelevanceEvaluator`, `CompletenessEvaluator` | **NÃO** — número reportado |

Um juízo de LLM que reprovasse sozinho reintroduziria exatamente o `FLAKY` que fez a decisão
D-F5-2 manter o eval fora do CI. A métrica de juízo continua útil (dá tendência entre
versões de prompt) sem precisar ser autoritativa — e, com a cache quente, ela é
determinística também, porque a resposta do juiz está na cache.

> **Dois dos quatro avaliadores de qualidade são `[Experimental("AIEVAL001")]` na 10.9.0**
> (`TaskAdherenceEvaluator` e `ToolCallAccuracyEvaluator`). Sob `TreatWarningsAsErrors`,
> usá-los exige suprimir um diagnóstico — uma decisão consciente, nunca um efeito colateral.
> `.Safety` e `.NLP` ficam fora por outro motivo: ambos são preview, e `.Safety` exige
> `TokenCredential` + Azure AI Foundry **na construção** — o oposto de "roda em CI sem
> credencial".

Um cenário **sem** asserção determinística não passa: ele não cobra nada. Deixá-lo verde é o
mesmo defeito de um teste vazio, e aqui seria pior, porque o cenário apareceria na contagem
como se tivesse verificado algo.

## Quando o eval roda

Declare-o como um nó a mais em `project.tests[]` — não como uma estrutura paralela:

```json
{
  "project": {
    "tests": [
      { "id": "unit", "command": "dotnet test tests/App.Tests/App.Tests.csproj" },
      {
        "id": "evals",
        "runner": "evals",
        "command": "dotnet test tests/App.Tests/App.Tests.csproj --filter-trait Category=Eval",
        "evals": { "cachePath": "tests/App.Tests/eval-cache", "envFile": ".env.evals" }
      }
    ],
    "evals": { "watch": ["tenants/**", "product/**", "config/model-registry.json"] }
  }
}
```

| Quando | O que acontece |
|---|---|
| `verify {feature} {task}` (loop de task) | **pulado**, com motivo registrado — o relógio de uma task não paga uma suíte de eval |
| `verify {feature}` (escopo de feature) | **roda**, offline, contra a cache commitada |
| miss de cache sem credencial | **reprova** nomeando o cenário, o caminho, o comando de regravação e o arquivo de credencial |
| `--refresh-cache` | regrava a árvore contra o provider real. **Nunca implícito**: é uma chamada paga |

**Miss jamais vira `skip`.** A tentação é real ("não dá para avaliar, então não avalio") e
recria o buraco que este harness já fechou duas vezes: gate verde sobre verificação que não
aconteceu. E há razão positiva: em CI, com cache commitada, um miss significa **uma coisa
só** — o prompt ou o modelo mudou e ninguém regravou. O miss é o veredito, não o obstáculo.

## `evals.watch[]`: o que conta como "mudei o prompt"

Os globs declaram, **para o projeto**, quais caminhos são prompt ou modelo. Eles são casados
contra o que a feature mudou (diff contra a branch default **mais** arquivos novos ainda não
rastreados) e alimentam a regra de Gate 3: tocou caminho declarado e não há eval verde
carimbado no mesmo commit ⇒ o gate **pausa** e nomeia o caminho.

Exclua `<cachePath>/**` e `<datasetPath>/**` dos globs. Sem isso, regravar a cache dispara a
própria regra.

O detector é por caminho, e as três formas conhecidas de escapar dele estão ditas em voz
alta, porque um detector que se apresenta como completo e não é vale menos que nenhum:

1. **eval trivialmente verde** — um cenário só, asserção vazia. É a mesma classe de contorno
   de um teste vazio, e a resposta é a rubrica de prova, não uma segunda regra determinística
   que também seria contornável;
2. **prompt publicado direto no banco**, sem tocar arquivo;
3. **modelo trocado por variável de ambiente** no provedor de deploy — invisível ao
   repositório por construção.

## Semeando a cache exemplar sem gastar dinheiro

A cache commitada de um kit ou de um projeto novo é gravada rodando o fixture em modo
`Refresh` contra um **fake roteirizado**, cujo `ChatClientMetadata` é configurado com o
mesmo `providerName`/`defaultModelId` que o cliente offline vai replicar.

Isto não é um atalho: o caminho de **escrita** exercitado é o da biblioteca, e a chave é
computada pela mesma função que a leitura vai usar. A leitura offline lê exatamente o que
aquela escrita produziu.

**A lacuna residual, declarada:** isso não prova que uma cache gravada contra um provider
real é legível offline. O que a mitiga é o fato medido acima — a chave depende de
`ProviderName` e `DefaultModelId`, e não do endpoint, e ambos estão sob nosso controle.

## Fronteira com `testing-ai`

| Standard | Cobre |
|---|---|
| `testing-ai` | Fake roteirizado rodando o loop real do agente; prova ferramenta escolhida, argumentos e guard. **Sem rede, sem modelo** |
| `evals-with-cache` (este) | Modelo **real** com cache em disco; dataset por tenant/agente; regressão de prompt e de modelo; o nó `evals` no `verify` |

Em uma frase: `testing-ai` prova que **o código em volta do modelo** se comporta;
`evals-with-cache` prova que **o modelo** ainda responde o que respondia.

## Se precisar mapear o blast radius de um prompt

`morph-spec graph explain <símbolo>` ajuda a achar quem lê um prompt ou um alias de modelo.
**Sem grafo construído, caia para `grep -rn` e siga em frente** — a ausência do grafo nunca
bloqueia este ciclo, e o fallback é a resposta correta, não um plano B envergonhado.
