# Guia USL — Upinion Survey Language (importação de questionários)

Este guia transforma um questionário escrito (Word, PDF, texto, planilha) em **USL**, o formato
JSON que a Upinion importa. Ele é autocontido: entregue este arquivo e o documento do
questionário a qualquer IA, e ela tem tudo o que precisa para gerar o JSON.

Tudo o que está aqui foi conferido contra o importador e o motor de coleta da Upinion. O que
existe no sistema mas não funciona de ponta a ponta pela importação está na seção 12, para não
ser usado.

---

## 0. Como usar este guia (para quem tem o questionário)

1. Abra uma conversa com a IA e envie **este arquivo** e **o documento do questionário**. Peça:
   *"Gere o USL deste questionário seguindo o guia."*
2. Na Upinion, abra o construtor e vá em **Importar → De arquivo ou texto**. Cole o JSON (ou
   escolha o arquivo `.json`) e clique em **Validar (dry-run)**. O dry-run não grava nada: ele
   lê o arquivo e aponta cada erro com o lugar exato.
3. Se aparecer erro, copie a lista inteira e devolva para a IA: *"O validador apontou estes
   erros, corrija e me devolva o JSON completo."* Repita até o dry-run passar.
4. Clique em **Importar**. A Upinion cria um questionário novo e abre o construtor nele.
5. Leia o campo `_pendencias` no fim do JSON (se houver): é o que a IA não conseguiu programar
   e você precisa ajustar no construtor. Faça uma prévia antes de publicar.

---

## 1. Instruções para a IA (leia antes de gerar)

### 1.1 O que entregar

- **Só o documento JSON**, um objeto, sem texto antes ou depois. Num chat, ele pode vir dentro
  de um único bloco de código `json`. JSON puro: sem comentários, sem vírgula sobrando.
- Use **só** as perguntas do documento, na ordem dele. Não invente pergunta, opção ou texto.
- Não pergunte nada de volta: gere o melhor JSON possível. O que você não conseguir expressar
  em USL, ou o que for ambíguo, vai numa lista de frases no campo de topo `_pendencias`
  (o importador ignora esse campo; ele existe para o humano ler):
  `"_pendencias": ["P12 pede rodízio de blocos: programar no construtor."]`

### 1.2 Regras de ouro

1. Estrutura: `meta` + `blocks[]` → `pages[]` → `elements[]`. `flow` e `endings` são
   opcionais (sem eles, o fluxo é linear e o fim é "complete"). Se houver triagem, cota ou
   desvio de bloco, escreva os dois.
2. Ponha `"screenoutMode": "immediate"` em `meta`: quem é filtrado sai na hora. A importação
   já assume esse valor quando o campo falta; escrevê-lo deixa o arquivo claro para quem o lê.
   `"deferred"` só se a triagem tiver de esperar o fim.
3. **Todo id explícito e legível** em pergunta, opção, linha, coluna, bloco, página e
   encerramento que apareça em lógica: `"id": "P3_marca"`. Use só letras sem acento, números e
   `_`, começando por letra. Sem hífen, sem ponto, sem espaço. Os ids automáticos correm pelo
   documento inteiro (`o1`, `o2`… seguem contando de uma pergunta para a outra), então nunca
   adivinhe um id automático numa condição.
4. Uma condição só olha para trás: pergunta da mesma página ou de página anterior.
5. `required` é `true` por padrão. Ponha `"required": false` no que for opcional.
6. Texto pode ser string (vira o idioma padrão) ou objeto por idioma `{"pt-BR": "..."}`.
7. Um bloco é um tema; uma página é uma tela. O respondente vê uma página por vez.
8. Idade mínima do produto é 18: se houver pergunta de idade, encerre quem tem menos de 18.
9. Tire do enunciado as marcas de programação (`RU`, `RM`, `ESTIMULADA`, `LER`, `PROGRAMADOR:`,
   códigos entre parênteses): elas viram estrutura e lógica, não texto para o respondente.
10. Ids de cota levam o nome do estudo (`cafe2026_sexo_f`, nunca `c1`): o contador de cota é
    compartilhado entre questionários que usam o mesmo id.

### 1.3 Do documento ao USL

| No documento está escrito | No USL vira |
|---|---|
| RU, resposta única, "assinale uma" | `single_choice`; `dropdown` se a lista passar de ~15 opções |
| RM, múltipla, "assinale todas que se aplicam" | `multi_choice` |
| "até 3 respostas" | `multi_choice` com `"maxSelections": 3` |
| "pelo menos 2 respostas" | `multi_choice` com `"minSelections": 2` (não vale para quem marca a opção exclusiva) |
| ESPONTÂNEA (sem mostrar opções) | `text_short` (uma resposta) ou `text_list` (várias, em campos) |
| ESTIMULADA, "mostrar cartão", "ler opções" | a pergunta de escolha normal, com as opções |
| "Outro (especifique)", "Outros: ____" | opção com `"allowText": true` e rótulo limpo `"Outro"` (5.2) |
| "Nenhuma das anteriores", "Nenhum" | opção com `"exclusive": true` (5.2) |
| "Não sei", "Prefiro não responder", NS/NR | opção com `"exclusive": true` em múltipla; última opção em única |
| "ROTAR", "RODIZIAR", "ALEATORIZAR as opções" | `"optionRandomization": {"strategy": "shuffle"}` (10.3) |
| "ROTAR as frases" de uma grade | `"rowRandomization": {"strategy": "shuffle"}` |
| "ROTAR as perguntas" P5 a P9 | uma pergunta por página num bloco com `"randomization": {"mode": "shuffle_pages"}` |
| "ROTAR os blocos", "MOSTRAR 2 DOS 4 BLOCOS" | nó `randomizer` no `flow` (10.3) |
| "ENCERRAR", "AGRADECER E ENCERRAR" (triagem) | `skipTo` para um encerramento `terminated` (7.2) |
| "SE NÃO, PULAR PARA P10", "IR PARA" | `skipTo` para a página da P10 (7.2) |
| "SÓ PARA QUEM RESPONDEU X NA P3", "SE CÓD. 1 NA P3" | `displayIf` na pergunta (ou na página/bloco) (6) |
| "MOSTRAR SÓ AS MARCAS CITADAS NA P5" | herança de lista: `displayIf` em cada opção (10.1) |
| "PARA CADA MARCA CITADA, PERGUNTAR P7 A P9" | bloco em loop (10.2) |
| "SORTEAR UMA MARCA" entre as citadas | sorteio, `draws` (9) |
| COTA, "50% homens", "máximo de 100 por região" | `_quotas` + `quota_check` no fluxo (7.4) |
| GRADE, MATRIZ, "para cada frase, dê uma nota" | `matrix_single` (com `scalePreset` se a escala for padrão) |
| "De 0 a 10, o quanto recomendaria" (e qualquer 0–10) | `nps` |
| Escala de 1 a 5 / 1 a 7 numa pergunta só | `single_choice` com as opções na ordem da escala |
| "ANOTAR NÚMERO", "quantos", idade, renda exata | `numeric` com `min`/`max` |
| "Ordene por preferência" | `rank_order` |
| "Distribua 100 pontos" | `constant_sum` |
| Texto de apresentação, "LER PARA O ENTREVISTADO" | elemento de texto `{"kind": "text"}` (2.3) |
| Termo de consentimento / LGPD | `consent`, com o termo na `description` |
| Estado, cidade | `drill_down` com a base `seed_localidades_br` (10.9) |
| E-mail, telefone, data | `email`, `phone`, `date` |
| "Seção A", "Bloco de hábitos" | um bloco |
| Nota de campo só para o entrevistador | não vira texto: vira lógica, ou vai para `_pendencias` |

---

## 2. Estrutura de topo

```jsonc
{
  "meta": { ... },          // obrigatório (title é o essencial)
  "variables": [ ... ],     // opcional — valores que chegam pela URL (seção 8)
  "calculated": [ ... ],    // opcional — valores calculados (seção 8)
  "draws": [ ... ],         // opcional — sorteios (seção 9)
  "blocks": [ ... ],        // obrigatório — as seções
  "flow": [ ... ],          // opcional — ordem, desvios, cotas (seção 7)
  "endings": [ ... ],       // opcional — telas finais (seção 7)
  "_quotas": [ ... ],       // opcional — cotas (seção 7.4)
  "exercises": { ... },     // opcional — conjoint e MaxDiff (seção 11)
  "_pendencias": [ ... ]    // opcional — o que ficou para o humano (ignorado na importação)
}
```

### 2.1 `meta`

| Campo | Valores | Padrão | Uso |
|---|---|---|---|
| `title` | texto | — | título da pesquisa (essencial) |
| `description` | texto | — | subtítulo |
| `screenoutMode` | `"immediate"` · `"deferred"` | `"immediate"` | encerra o filtrado na hora; `"deferred"` deixa para o fim |
| `defaultLocale` | `"pt-BR"`, `"en"`, `"es"` | `"pt-BR"` | idioma base |
| `locales` | lista de idiomas | `[defaultLocale]` | idiomas do questionário (10.10) |
| `backButton` | bool | `false` | permite voltar |
| `inactivityTimeoutMin` | int | `45` | minutos sem atividade até a sessão expirar |
| `theme` | objeto | — | cores, fonte e logo (10.8) |

### 2.2 Bloco e página

```jsonc
{
  "id": "B_perfil",                    // bloco
  "title": "Perfil",                   // opcional — mostra o cabeçalho do bloco
  "displayIf": {"if": "..."},          // opcional — o bloco só aparece se...
  "randomization": {"mode": "shuffle_pages"},  // opcional — embaralha as páginas do bloco
  "pages": [
    { "id": "P_perfil1", "displayIf": {"if": "..."}, "elements": [ ... ] }
  ]
}
```
Atalho: um bloco com `elements` e **sem** `pages` vira uma página por elemento.

### 2.3 Elementos que não são pergunta

- Texto: `{"kind": "text", "id": "T_intro", "html": "<b>Bem-vindo!</b><p>Leva 5 minutos.</p>"}`.
  Aceita HTML simples (`<b>`, `<i>`, `<p>`, `<br>`, `<ul>`, `<li>`).
- Imagem: `{"kind": "image", "id": "IMG_conceito", "url": "https://...", "caption": "Conceito A", "align": "center"}`.
- Texto e imagem aceitam `displayIf`, nas duas formas (6.3): o texto aparece só para quem a
  condição alcança.
- Tipos com widget próprio (`matrix_single`, `bipolar`, `slider`, `hotspot`, `heatmap_click`,
  `drill_down`, `autocomplete_dataset`, `cbc_task`, `maxdiff_task`) são desenhados **antes** dos
  outros elementos da mesma página. Um texto de instrução antes de uma matriz aparece embaixo
  dela. Ponha a instrução na `description` da própria pergunta, ou numa página só dela.

---

## 3. A pergunta (campos comuns a todos os tipos)

```jsonc
{
  "id": "P5_nps",                    // recomendado; obrigatório se usado em lógica
  "type": "nps",                     // obrigatório (seção 4)
  "text": "O quanto recomendaria?",  // enunciado
  "description": "opcional",         // texto de apoio, logo abaixo do enunciado
  "required": true,                  // padrão true
  "displayIf": {"if": "..."},        // mostra só se (seção 6)
  "requiredIf": {"if": "..."},       // obrigatória só se
  "skipTo": [                        // ao enviar a página, pula para outro ponto
    { "if": "P5_nps <= 6", "target": {"kind": "page", "pageId": "P_motivo"} }
  ],
  "validation": { "regex": "^[0-9]{8}$", "message": "Digite os 8 números do CEP." }
}
```
- `skipTo[].target.kind`: `"page"` (+`pageId`), `"block"` (+`blockId`) ou `"ending"` (+`endingId`).
  As regras são lidas em ordem; vale a primeira verdadeira. Nunca aponte para trás (vira laço).
- `validation` (textos): `regex` + `message`, `minLength`, `maxLength`, `format`
  (`"email"` ou `"phone"`). `minLength`/`maxLength` também valem direto na pergunta.
- Verificação entre perguntas (renda familiar ≥ individual): seção 10.6.

---

## 4. Catálogo de tipos (todos os que funcionam)

Cada linha: `type` → campos específicos → valor gravado (o que a condição compara).

### Escolha
| `type` | Campos | Valor gravado |
|---|---|---|
| `single_choice` | `options`; opc: `columnsLayout` (nº de colunas) | `"idDaOpcao"` |
| `single_choice_comment` | `options` + uma caixa de comentário livre; opc: `commentLabel` (rótulo da caixa) | `"idDaOpcao"`; o comentário sai na coluna `<var>_comment` e a condição não o lê |
| `multi_choice` | `options`; opc: `maxSelections`, `minSelections`, `selectAllOption` (bool) | `["id1","id2"]` |
| `dropdown` | `options` (lista longa, mostra numa caixa) | `"idDaOpcao"` |
| `tagbox` | `options` (múltipla com busca); opc: `maxSelections`, `minSelections` | `["id1"]` |
| `image_choice` | `options`, cada uma com `image` (URL); opc: `multiSelect` | `"id"` (ou lista com `multiSelect`) |
| `yes_no` | opc: `yesLabel`, `noLabel` | `"yes"` ou `"no"` |

### Texto e número
| `type` | Campos | Valor gravado |
|---|---|---|
| `text_short` | opc: `placeholder`, `minLength`, `maxLength` | `"..."` |
| `text_long` | opc: `placeholder`, `minLength`, `maxLength` | `"..."` |
| `email` / `phone` | — (o formato é conferido) | `"..."` |
| `numeric` | opc: `min`, `max` (conferidos) | `42` |
| `date` | opc: `collectTime` (bool) | `"2026-01-30"` |
| `text_list` | `items` (um campo de texto por item) | `{"itemId":"..."}` |
| `numeric_list` | `items` (um campo numérico por item) | `{"itemId":5}` |
| `form_fields` | `fields`: objetos `{"id","label"}` (todos viram campo de texto) | `{"fieldId":"..."}` |

### Escalas e avaliação
| `type` | Campos | Valor gravado |
|---|---|---|
| `nps` | opc: `leftLabel`, `rightLabel` (escala 0 a 10) | `9` |
| `star_rating` | opc: `iconCount` (padrão 5) | `4` |
| `emoji_rating` | opc: `iconCount` (padrão 5) | `3` |
| `slider` | opc: `min` (0), `max` (100), `step` (1), `leftLabel`, `rightLabel` | `70` |

### Matrizes (linhas × colunas)
Obrigatória em matriz quer dizer **toda linha visível** respondida.

| `type` | Campos | Valor gravado |
|---|---|---|
| `matrix_single` | `rows` + (`columns` **ou** `scalePreset`) | `{"linhaId":"colunaId"}` |
| `bipolar` | igual a `matrix_single`; as colunas são os pontos entre dois polos | `{"linhaId":"colunaId"}` |
| `matrix_multi` | `rows` + `columns` (marca várias por linha) | `{"linhaId":{"colunaId":true}}` |
| `matrix_text` | `rows` + `columns` (texto em cada célula) | `{"linhaId":{"colunaId":"..."}}` |
| `matrix_numeric` | `rows` + `columns` (número em cada célula) | `{"linhaId":{"colunaId":"5"}}` |
| `matrix_dropdown` | `rows` + (`columns` ou `scalePreset`) = as opções da caixa de cada linha | `{"linhaId":{"resp":"colunaId"}}` |
| `matrix_dual_scale` | `rows`, `columnsA`, `columnsB` (objetos com `id`); opc: `headerA`, `headerB` | `{"linhaId":{"columnsA":"id","columnsB":"id"}}` |
| `side_by_side` | `rows`, `columnGroups` (ver 5.4) | `{"linhaId":{"grupoId":...}}` |

### Ordenação e alocação
| `type` | Campos | Valor gravado |
|---|---|---|
| `rank_order` | `options`; opc: `rankTopN` | `{"optId":1,"outro":2}` |
| `constant_sum` | `options` (os itens); opc: `total` (padrão 100, mostrado ao respondente) | `{"optId":30}` |
| `budget_scale` | igual a `constant_sum` (apresentado como orçamento) | `{"optId":30}` |

### Formulário e utilidade
| `type` | Campos | Valor gravado |
|---|---|---|
| `consent` | `text` (pergunta curta), **o termo vai na `description`**; opc: `yesLabel`, `noLabel` | `"yes"` ou `"no"` |
| `signature` | — | imagem da assinatura |
| `file_upload` | opc: `acceptedTypes` (`"image/*,.pdf"`), `allowMultiple`, `maxSize` (bytes) | lista de arquivos |
| `drill_down` | `datasetId`, `levels` (rótulos dos níveis, ver 10.9) | `["SP","São Paulo"]` |
| `autocomplete_dataset` | `datasetId`, `labelColumn`; opc: `valueColumn`, `allowFreeText`, `minChars` | `{"value":"id","free_text":""}` |

### Mídia
| `type` | Campos | Valor gravado |
|---|---|---|
| `heatmap_click` | `image` (URL); opc: `minClicks`, `maxClicks`, `clickLabel` | `[{"x":0.42,"y":0.31}]` |
| `hotspot` | `image`, `regions`: `{"id","label","x","y","w","h"}` em frações de 0 a 1; opc: `maxSelections` | `["regiaoId"]` |

### Conjoint e MaxDiff — `cbc_task` e `maxdiff_task`
Uma tarefa por página, sempre presa a um exercício declarado em `exercises` (seção 11). O
documento completo de cada um está lá.

> Tipos não listados aqui (ex.: `van_westendorp`, `matrix_dynamic`, `multi_choice_comments`)
> existem no schema mas ainda **não renderizam** — não use em import.

---

## 5. Listas: `options`, `rows`, `columns`, `items`, `fields`

### 5.1 Forma curta e objeto

```jsonc
"options": ["Sim", "Não"]                                              // ids automáticos
"options": [ {"id":"sim","label":"Sim"}, {"id":"nao","label":"Não"} ]  // ids fixos (prefira)
```
- A forma curta (lista de textos) vale em `options`, `rows`, `columns`, `items`, `features`,
  `columnsA`, `columnsB`, `fields` e nas `options` de cada grupo de `columnGroups`. Os grupos
  de `columnGroups` e as `regions` são sempre objetos (com `inputType` e com `x`/`y`/`w`/`h`,
  respectivamente); o `id` é gerado quando falta, mas o fixo é melhor.
- Campos de cada opção, linha ou coluna: `id`, `label`, `code` (número para a exportação),
  `image` (URL), `exclusive`, `allowText` + `otherPlaceholder`, `fixedPosition` (não sai do
  lugar quando a lista é embaralhada), `group` (para embaralhar por grupo) e `displayIf`
  (10.1). Para pontuar respostas, use uma calculada (10.5).
- Rótulo de opção, linha e coluna é texto fixo: `{{...}}` não é substituído ali (seção 8.4).

### 5.2 "Outro" e "Nenhuma"

- **Outro:** uma opção com `"allowText": true` e rótulo limpo (`"Outro"`), em vez de uma
  alternativa comum. Abre um campo de texto. No máximo uma por pergunta; fica sempre no fim e
  nunca é embaralhada. Vale para `single_choice`, `multi_choice`, `dropdown` e `tagbox`.
- **Nenhuma:** `"exclusive": true` e rótulo limpo. Em múltipla, marcar ela desmarca as outras;
  em qualquer lista, ela fica ancorada por último. Pode haver mais de uma ("Nenhuma das
  anteriores" e "Prefiro não responder").
- Vale igual em inglês e espanhol ("Other (please specify)", "None of the above", "Ninguna").

```jsonc
"options": [ {"id":"radio","label":"Rádio"}, {"id":"tv","label":"TV"},
             {"id":"outro","label":"Outro","allowText":true},
             {"id":"nenhuma","label":"Nenhuma das anteriores","exclusive":true} ]
```
A resposta de "Outro" grava o id e o texto: `{"value":"outro","other_text":"Podcast"}`.

### 5.3 `scalePreset` (gera as `columns` de uma matriz)

| preset | colunas (ids `c1`, `c2`…) |
|---|---|
| `likert5_agree` | Discordo totalmente → Concordo totalmente |
| `likert5_sat` | Muito insatisfeito → Muito satisfeito |
| `points5` / `points10` | 1…5 / 1…10 |
| `freq5` | Nunca → Sempre |
| `yes_no_uncertain` | Sim / Não / Incerto |
| `increase_same_decrease` | Aumentou / Igual / Diminuiu |

Vale em `matrix_single`, `bipolar` e `matrix_dropdown`. As colunas geradas têm ids `c1`…`cN`
na ordem da escala: "concordo totalmente" é `c5`.

### 5.4 `columnGroups` (do `side_by_side`)

```jsonc
"columnGroups": [
  {"id":"uso","header":"Usa?","inputType":"single",
   "options":[{"id":"s","label":"Sim"},{"id":"n","label":"Não"}]},
  {"id":"gasto","header":"Gasto mensal (R$)","inputType":"numeric"}
]
```
`inputType`: `single` (padrão), `dropdown`, `multi`, `text`, `numeric`.

---

## 6. Condições

Uma condição aparece em `displayIf`, `requiredIf`, `skipTo[].if`, no `branch` do fluxo e no
`displayIf` de opção, página e bloco. Há duas formas de escrever.

### 6.1 Forma curta: `{"if": "..."}`

| Categoria | Operadores |
|---|---|
| comparação | `==`  `!=`  `>`  `>=`  `<`  `<=` |
| combinação | `and`  `or`  `not`  `( )` |
| contagem | `count(pergunta)` com `==` `>` `>=` `<` `<=` |

- **Escolha:** `P3 == 'nao'` — `'nao'` é o **id da opção**; `==` quer dizer "marcou",
  `!=` "não marcou". Em múltipla, `P5 == 'nu'` = "marcou Nubank".
- **Número:** `idade >= 18`, `P5_nps <= 6`.
- **Linha de matriz:** `{sat.r_preco} == 'c5'` — a pergunta e a linha entre chaves, separadas
  por ponto; o valor é o **id da coluna** (nunca o número da escala).
- **Sim/Não:** para usar em lógica, prefira `single_choice` com opções `sim`/`nao`
  (`fuma == 'sim'`). Numa `yes_no`, a forma curta é recusada; use a completa (6.2) com
  `"operator": "eq", "operand": "yes"`.
- **Variável, calculada e sorteio:** `{var.canal} == 'email'`, `{calc.faixa} == 'senior'`,
  `{var.marca} == 'pilao'` (sorteio se lê por `{var.nome}`, nunca `{draw.nome}`).
- **Quantos marcou:** `count(P5) >= 3`.

```json
{ "if": "idade >= 18 and regiao == 'sudeste'" }
{ "if": "P5_nps <= 6 or satisfacao == 'ruim'" }
{ "if": "not (plano == 'free')" }
{ "if": "count(marcas) > 1" }
```
Sem resposta, toda comparação é falsa. Atenção: `P3 != 'x'` também é falso para quem não
respondeu a P3, mas `not (P3 == 'x')` é verdadeiro.

### 6.2 Forma completa (para o que a curta não escreve)

```jsonc
{ "source": {"kind":"question","questionId":"P5"}, "operator": "any_selected", "operand": ["nu","it"] }
{ "op": "and", "children": [ <condição>, <condição> ] }        // "or" igual; "negate": true inverte
```
- `source.kind`: `question` (+`questionId`, e `sub` para linha de matriz), `variable` (+`name`),
  `calculated` (+`name`), `device` (`"mobile"`/`"desktop"`), `random_pct` (0 a 99).
- Operadores: `selected`, `not_selected`, `any_selected`, `none_selected`, `all_selected`
  (estes três com lista), `selected_count_eq`/`_gte`/`_lte`, `eq`, `neq`, `gt`, `gte`, `lt`,
  `lte`, `between` (`[mín, máx]`), `contains`, `not_contains`, `matches_regex` (texto),
  `is_answered`, `is_not_answered`, `rank_of_eq`/`rank_of_lte` (`{"optionId","rank"}`).
- Exemplos: respondeu? `{"source":{"kind":"question","questionId":"P7"},"operator":"is_answered"}`;
  idade entre 18 e 24: `{"source":{"kind":"question","questionId":"idade"},"operator":"between","operand":[18,24]}`.

### 6.3 Onde cada forma vale

| Lugar | Forma curta `{"if"}` | Forma completa |
|---|---|---|
| `displayIf`/`requiredIf` de pergunta, opção, página, bloco | sim | sim |
| `enableIf` de opção | sim | sim |
| `displayIf` de texto, imagem e grupo | sim | sim |
| `skipTo[].if` e `condition` do `branch` | sim | sim |
| `condition` de célula de cota (`_quotas`) e `if` de gatilho (`triggers`) | sim | sim |

Nunca escreva `{"all": [...]}`, `{"any": [...]}` nem `{"if": "...", "outra": ...}`: o validador
os recusa (`CONDITION_MALFORMED`), e antes eles quebravam a coleta. Combine com `and`/`or` na
forma curta ou com `op` na completa.

---

## 7. Fluxo, triagem, encerramentos e cotas

### 7.1 `flow` e `endings`

Sem `flow`: os blocos na ordem e um fim "complete". Com `flow`:

| Nó | Forma |
|---|---|
| bloco | `{"kind":"block","blockId":"B1"}` |
| desvio | `{"kind":"branch","condition":{"if":"..."},"children":[ ...nós... ]}` — os filhos só rodam se a condição for verdadeira |
| encerramento | `{"kind":"ending","endingId":"fim"}` |
| corte de cota | `{"kind":"quota_check","quotaId":"cafe2026_sexo"}` |
| gravar variável | `{"kind":"set_variables","assignments":[{"name":"trilha","value":"A"}]}` (8.3) |

```jsonc
"endings": [
  { "id":"fim",    "status":"complete",   "label":"Fim",          "message":"Obrigado por participar!" },
  { "id":"filtro", "status":"terminated", "label":"Não elegível", "message":"Obrigado! A pesquisa não segue para você." },
  { "id":"cheia",  "status":"overquota",  "label":"Cota cheia",   "message":"Obrigado! Já temos respostas suficientes do seu perfil." }
]
```
`status`: `complete` (respondeu tudo) · `terminated` (filtrado na triagem) · `overquota` (cota
cheia) · `quality_terminated` (barrado por qualidade, 10.7). Declare todo encerramento que o
`skipTo` ou o `flow` citar. O link de retorno para o painel não entra no JSON: ele é
configurado no coletor, na Upinion.

### 7.2 Triagem (filtro)

A forma recomendada é um `skipTo` na própria pergunta-filtro para um encerramento
`terminated`, com `"screenoutMode": "immediate"` em `meta`:
```jsonc
{ "id":"idade", "type":"numeric", "text":"Qual a sua idade?", "min":0, "max":120,
  "skipTo":[ {"if":"idade < 18", "target":{"kind":"ending","endingId":"filtro"}} ] }
```
Um `branch` no fluxo com um nó `ending` dentro também encerra, mas só quando o fluxo chega
nele (depois do bloco anterior inteiro).

### 7.3 Pular para outra parte

`skipTo` com `"kind": "page"` ou `"kind": "block"`. Para desviar blocos inteiros por perfil,
use `branch` no fluxo.

### 7.4 Cotas

Cada cota tem células (condição + alvo). Ponha um `quota_check` no fluxo **depois** do bloco em
que o perfil é respondido: quem cai numa célula cheia vai para o encerramento `overquota`.
A condição da célula aceita as duas formas (6.3). Ids levam o nome do estudo.
```jsonc
"_quotas": [ { "id":"cafe2026_sexo", "name":"Sexo", "cells": [
  { "id":"cafe2026_sexo_f", "name":"Feminino", "target":150,
    "condition": {"source":{"kind":"question","questionId":"sexo"},"operator":"selected","operand":"f"} },
  { "id":"cafe2026_sexo_m", "name":"Masculino", "target":150,
    "condition": {"source":{"kind":"question","questionId":"sexo"},"operator":"selected","operand":"m"} } ] } ],
"flow": [ {"kind":"block","blockId":"B_perfil"}, {"kind":"quota_check","quotaId":"cafe2026_sexo"},
          {"kind":"block","blockId":"B_main"}, {"kind":"ending","endingId":"fim"} ]
```
- Cota cruzada (sexo × região): uma célula por combinação, com `{"op":"and","children":[...]}`.
- Faixa de idade: `"operator":"between","operand":[18,24]`.
- `target` 0 ou ausente = célula sem limite.

---

## 8. Variáveis, URL, calculadas e piping

### 8.1 Variáveis (`variables`)

```jsonc
"variables": [ {"name":"canal","type":"text","defaultValue":"site","exportable":true} ]
```
- Todo parâmetro do link de entrada chega como variável pelo nome: `...?canal=email` faz
  `{var.canal}` valer `email`. O valor da URL vence o `defaultValue`.
- `type`: `text` ou `number`. Nome: letra, depois letras/números/`_`. Não use `c`, `score` nem
  nome começando com `_`.
- Declare a variável para usá-la em condição.
- Os parâmetros de painel (`rid`, `pid`, `gid`, `psid`, `utm_*`) já chegam sozinhos: **não os
  declare**, a não ser que precise ramificar por eles (nesse caso declare sem `defaultValue`).

### 8.2 Calculadas (`calculated`)

```jsonc
"calculated": [
  {"name":"faixa","expression":"iif({idade} >= 45, 'madura', iif({idade} >= 30, 'media', 'jovem'))",
   "recompute":"on_page_submit","exportable":true}
]
```
- Referências entre chaves: `{idade}` (resposta), `{sat.r1}` (linha), `{var.x}`, `{calc.x}`.
  Comparação com o rótulo de uma opção funciona: `{P1} = 'Pilão'`.
- Operadores `+ - * / %`, comparações, `and or not`, listas `['a','b']`.
- Funções: `sum min max round abs len count iif age answered contains`, e as de conjunto
  `union(a,b)`, `diff(a,b)`/`exclude(a,b)`, `intersect(a,b)`.
- Para sortear, **não** use calculada: use `draws` (seção 9).

### 8.3 Gravar uma variável no meio do fluxo

```jsonc
{"kind":"set_variables","assignments":[
  {"name":"trilha","value":{"expr":"iif({idade} >= 40, 'senior', 'jovem')"}} ]}
```
`value` é um literal (`"A"`, `10`) ou `{"expr": "..."}`. A variável precisa estar declarada.

### 8.4 Piping (inserir resposta no texto)

`"text": "Por que você deu nota {{P5_nps}} para a {{draw.marca}}?"`
- Refs: `{{P5_nps}}` (resposta), `{{var.canal}}`, `{{calc.faixa}}`, `{{draw.marca}}` (rótulo
  sorteado), `{{loop.label}}` (item do loop).
- Funciona em: enunciado e `description` da pergunta, `html` de texto, legenda de imagem,
  títulos de página e bloco, e `message` de encerramento.
- **Não funciona** em rótulo de opção, linha ou coluna.

---

## 9. Sorteio (`draws`)

Sorteia item(ns) para cada respondente. O valor congela: voltar e mudar a resposta não sorteia
de novo.

```jsonc
"draws": [ { "name":"marca", "source":{"questionId":"conhece"},
             "exclude":"['outro','nenhuma']", "balance":true,
             "whenEmpty":"screenout", "whenEmptyEndingId":"filtro", "exportable":true } ]
```
- `name`: nome do sorteio (regras de nome de variável).
- `source` (uma das três):
  - pergunta: `{"questionId":"conhece","pool":"selected"}` — `pool`: `selected` (padrão),
    `not_selected`, `all`, ou `ranked_top_n` + `topN` (os N primeiros de um `rank_order`);
  - lista fixa: `{"items":[{"id":"pilao","label":"Pilão"}, ...]}`;
  - fórmula: `{"expression":"..."}` que devolve uma lista de ids.
- `count`: quantos itens sortear (1 a 50; padrão 1).
- `exclude`: fórmula com os ids que não entram (ex.: `"['outro','nenhuma']"`).
- `balance`: `true` equilibra quantas vezes cada item sai entre os respondentes (padrão `false`).
- `target`: meta de completas para **cada** item (com `balance`, o sorteio corre atrás dela);
  `targetByItem` (`{"pilao":100}`) dá a um item uma meta própria. Meta 0 = sem meta.
- `fallbackSources`: lista de fontes extras (mesma forma de `source`) que completam a vaga
  quando a principal não tem itens suficientes.
- `whenEmpty`: `empty` (padrão: segue com o sorteio vazio) ou `screenout` (encerra na hora;
  exige `whenEmptyEndingId` apontando para um encerramento `terminated` declarado). Quando a
  fonte tem "Outro" ou "Nenhuma" no `exclude`, quem marcou só eles fica sem item: use
  `screenout`, senão o enunciado com `{{draw.x}}` aparece com um buraco no lugar da marca.

Como usar o valor:
- No texto: `{{draw.marca}}` mostra o rótulo. Com `count` maior que 1, `{{draw.marca}}` lista
  todos, e `{{draw.marca1}}`, `{{draw.marca2}}`… mostram cada vaga.
- Na condição: `{var.marca} == 'pilao'` (o id). Vaga preenchida?
  `{"source":{"kind":"variable","name":"marca2"},"operator":"is_answered"}`.
- Para uma pergunta por item sorteado, faça uma pergunta por vaga (`nota_marca1`,
  `nota_marca2`), com o rótulo no enunciado.

---

## 10. Recursos avançados

### 10.1 Herança de lista — "só as marcas citadas antes"

Cada opção da pergunta que herda tem um `displayIf` que olha a mesma opção numa pergunta
anterior. A pergunta-fonte precisa estar numa **página anterior**.
```jsonc
// página 1
{ "id":"conhece", "type":"multi_choice", "text":"Quais marcas você conhece?",
  "options":[{"id":"nu","label":"Nubank"},{"id":"it","label":"Itaú"},{"id":"pp","label":"PicPay"}] }
// página seguinte
{ "id":"usa", "type":"multi_choice", "text":"Das que conhece, quais você usa?",
  "options":[
    {"id":"nu","label":"Nubank","displayIf":"conhece == 'nu'"},
    {"id":"it","label":"Itaú","displayIf":"conhece == 'it'"},
    {"id":"pp","label":"PicPay","displayIf":"conhece == 'pp'"}] }
```
- Opção sem `displayIf` aparece para todos (ex.: uma "Outra" fixa).
- Herdar de duas perguntas: `"displayIf": "bancos == 'nu' or cartoes == 'nu'"`.
- As **não** citadas: `"displayIf": "conhece != 'nu'"`.
- Para mostrar só N das herdadas: some `"optionRandomization":{"strategy":"subset_n","subsetN":3}`.

### 10.2 Bloco em loop — repetir perguntas por item

Um bloco com `loop` repete suas páginas para cada item. A pergunta-fonte fica num **bloco
anterior** (nunca dentro do próprio bloco em loop).
```json
{
  "meta": { "title": "Avaliação por banco", "screenoutMode": "immediate" },
  "blocks": [
    { "id":"B_conhece", "pages":[ { "elements":[
      { "id":"conhece", "type":"multi_choice", "text":"Quais bancos você conhece?",
        "options":[ {"id":"nu","label":"Nubank"}, {"id":"it","label":"Itaú"},
                    {"id":"pp","label":"PicPay"} ] } ] } ] },
    { "id":"B_aval",
      "loop": { "sourceQuestionId":"conhece", "pool":"selected", "max":2 },
      "pages":[ { "elements":[
        { "id":"nota", "type":"nps", "text":"De 0 a 10, quanto você recomendaria o {{loop.label}}?" },
        { "id":"motivo", "type":"text_long", "required":false, "text":"Por quê?" } ] } ] }
  ]
}
```
- `pool`: como no sorteio (`selected`, `not_selected`, `all`, `ranked_top_n` + `topN`).
  Fonte de resposta única: use `"pool":"all"`.
- `max`: teto de itens (padrão 8); acima dele, sorteia `max` itens, estável por respondente.
  `"randomizeOrder": true` embaralha a ordem.
- Catálogo fixo em vez de pergunta: `"catalog":[{"id":"pulse","label":"Fiat Pulse","weight":2,
  "category":"suv","image":"https://..."}, ...]` + `max`; opc: `minCategories` (mistura de
  categorias). O sorteio respeita `weight`.
- Imagem por item: `"images":{"nu":"https://..."}`; no texto, `{{loop.image}}`.
- Piping: `{{loop.label}}`, `{{loop.id}}`, `{{loop.index}}`. As respostas ficam por item
  (`nota#nu`, `nota#it` na exportação).

### 10.3 Randomização

- Opções: `"optionRandomization": {"strategy": "shuffle"}`. Estratégias: `shuffle`,
  `rotation`, `reverse_random`, `alphabetical`, `subset_n` (+ `"subsetN": 3`, mostra só N),
  `shuffle_groups` e `groups_and_items` (pelo `group` de cada opção). "Outro", "Nenhuma" e
  opções com `"fixedPosition": true` não saem do lugar.
- Linhas de matriz: `"rowRandomization": {"strategy": "shuffle"}`.
- Páginas de um bloco: `"randomization": {"mode": "shuffle_pages"}` no bloco. Para rodar
  perguntas, ponha uma por página nesse bloco.
- Blocos inteiros: um nó `randomizer` no `flow`, com os blocos dentro dele e não soltos ao
  lado. `presentCount` mostra só N deles por pessoa (`null` mostra todos):
  `{"kind": "randomizer", "id": "R1", "presentCount": null, "evenly": false,
  "children": [{"kind": "block", "blockId": "B_marcaA"}, {"kind": "block", "blockId": "B_marcaB"}]}`.
  Cada bloco aparece inteiro, com as páginas na ordem dele. Com `presentCount`, cada bloco
  tem a mesma chance, sorteada pessoa a pessoa: `evenly` ainda não reparte por contador.
- Perguntas de uma página: ponha-as num grupo,
  `{"kind": "group", "randomizeElements": true, "elements": [ ...perguntas... ]}`. No
  construtor ele aparece como "embaralhar as perguntas da página"; título e condição no grupo
  não são editáveis lá, então prefira a condição na página.
- A ordem sorteada fica gravada por respondente.

### 10.4 Teste A/B do enunciado

```jsonc
{ "id":"cta", "type":"single_choice", "text":"(texto padrão)",
  "abTest": { "variants": [
    {"id":"v1","text":{"pt-BR":"Você compraria este produto?"}},
    {"id":"v2","text":{"pt-BR":"Você recomendaria este produto a um amigo?"}} ] },
  "options":[{"id":"s","label":"Sim"},{"id":"n","label":"Não"}] }
```
Cada respondente vê uma variante, e a variante fica gravada. O texto da variante é sempre
objeto por idioma.

### 10.5 Pontuação (quiz e índices)

Declare uma calculada que soma os pontos, comparando pelo rótulo da opção:
```jsonc
"calculated": [ {"name":"pontos", "recompute":"on_page_submit", "exportable":true,
  "expression":"iif({q1} = 'Brasília', 10, 0) + iif({q2} = 'Azul', 5, 0)"} ]
```
O total aparece no texto com `{{calc.pontos}}` e entra em condição com `{calc.pontos} >= 10`.

### 10.6 Verificação entre perguntas

```jsonc
{ "id":"renda_fam", "type":"numeric", "text":"Renda familiar mensal (R$)",
  "validation": { "expression":"{renda_fam} >= {renda_ind}",
                  "expressionMessage":"A renda familiar não pode ser menor que a sua." } }
```
A expressão usa a sintaxe das calculadas (8.2) e só é cobrada quando todas as perguntas citadas
têm resposta. Confira os ids: um id errado faz a regra nunca barrar. Para faixas de renda em
duas perguntas de escolha, prefira perguntar o valor (`numeric`).

### 10.7 Qualidade

- Pergunta de atenção: numa `single_choice`, `"isAttentionCheck": true` e
  `"correctOptionId": "id_certo"`. Quem erra fica marcado na base.
- Para encerrar quem erra: `skipTo` para um encerramento `quality_terminated`. Esse
  encerramento sempre espera o fim do questionário.
- Velocidade e respostas em linha reta são marcadas sozinhas.
- Para trocar os limites deste questionário, o bloco `quality` no topo:
  `"quality": {"minLoiSeconds": 300, "straightliningThreshold": 0.9,
  "maxFailedAttention": 1, "attentionChecksRequired": 2}`. Tempo mínimo da entrevista
  completa em segundos; fração das linhas da matriz na mesma coluna; armadilhas que a
  pessoa pode errar; armadilhas que ela precisa acertar (só conta a que ela viu). As
  regras só marcam a resposta na pontuação de qualidade: não encerram ninguém.

### 10.8 Tema (aparência)

```jsonc
"meta": { "title":"...", "theme": {
  "primaryColor":"#0f766e", "backgroundColor":"#f5f6f8", "textColor":"#151827",
  "fontFamily":"inter", "logo":"https://.../logo.png", "logoPosition":"left",
  "borderRadius":10, "containerWidth":720, "showUpinionFooter":true } }
```
`fontFamily`: `system`, `inter`, `roboto`, `lato`, `montserrat`, `opensans`, `poppins`,
`merriweather`. `logoPosition`: `left`, `center`, `right`.

### 10.9 Bases de dados (CSV)

`drill_down` e `autocomplete_dataset` leem uma base (CSV) da Upinion pelo `datasetId`. A base
`seed_localidades_br` (UF → Município → Bairro) já existe para todos:
```json
{ "id":"local", "type":"drill_down", "text":"Onde você mora?",
  "datasetId":"seed_localidades_br", "levels":["Estado","Cidade","Bairro"] }
```
Outra base precisa ser criada antes na Upinion (menu Bases do construtor); sem ela, deixe a
pergunta como `dropdown` e registre em `_pendencias`.

### 10.10 Idiomas

Com `"locales": ["pt-BR","en"]`, escreva todo texto como objeto: `{"pt-BR":"Idade?","en":"Age?"}`.
Texto faltando num idioma vira aviso `TRANSLATION_MISSING`.

### 10.11 Imagens

Toda imagem (`image` de opção, `heatmap_click`, `hotspot`, `kind: image`, `logo`) é uma URL
pública `https://`. Se o documento só traz a imagem colada, use um endereço provisório e
registre em `_pendencias` para trocar no construtor.

---

## 11. Métodos de pesquisa de mercado

### 11.1 Conjoint (CBC)

O exercício fica em `exercises.cbc[]` (atributos × níveis + configuração) e há uma pergunta
`cbc_task` por tarefa, uma por página, com `exerciseId` e `taskIndex` (de 0 a
`tasksPerRespondent - 1`). Os conceitos de cada tarefa são gerados pela Upinion ao publicar.

```jsonc
{
  "exercises": { "cbc": [ {
    "id": "E1",
    "attributes": [
      { "id":"marca", "name":"Marca",
        "levels":[ {"id":"m1","label":"Marca A"}, {"id":"m2","label":"Marca B"} ] },
      { "id":"preco", "name":"Preço",
        "levels":[ {"id":"p1","label":"R$ 9,90"}, {"id":"p2","label":"R$ 14,90"} ] }
    ],
    "tasksPerRespondent": 8,      // nº de telas de escolha
    "conceptsPerTask": 3,         // opções por tela
    "noneOption": "traditional",  // "traditional" mostra "Nenhuma"; "none" oculta
    "designMethod": "balanced_overlap",
    "designVersions": 20, "seed": 42
  } ] },
  "blocks": [ { "id":"B_cbc", "pages": [
    { "elements":[ {"id":"CBC1","type":"cbc_task","text":"Qual escolheria?","exerciseId":"E1","taskIndex":0} ] },
    { "elements":[ {"id":"CBC2","type":"cbc_task","text":"Qual escolheria?","exerciseId":"E1","taskIndex":1} ] }
    // ... uma página por tarefa
  ] } ]
}
```

### 11.2 MaxDiff (melhor e pior)

Igual ao CBC, com uma lista de itens. Regra: **nº de itens ≥ 2 × `itemsPerTask`**.

```jsonc
{
  "exercises": { "maxdiff": [ {
    "id":"M1",
    "items":[ {"id":"i1","label":"Preço baixo"}, {"id":"i2","label":"Entrega rápida"},
              {"id":"i3","label":"Boa marca"}, {"id":"i4","label":"Atendimento"},
              {"id":"i5","label":"Variedade"}, {"id":"i6","label":"Site fácil"},
              {"id":"i7","label":"Pontos"}, {"id":"i8","label":"Sustentabilidade"} ],
    "itemsPerTask": 4, "tasksPerRespondent": 6, "designVersions": 20, "seed": 7,
    "bestLabel":"Mais importante", "worstLabel":"Menos importante"
  } ] },
  "blocks": [ { "id":"B_md", "pages": [
    { "elements":[ {"id":"MD1","type":"maxdiff_task","text":"Mais e menos importante?","exerciseId":"M1","taskIndex":0} ] }
    // ... uma página por tarefa
  ] } ]
}
```

### 11.3 Preço — Van Westendorp

Quatro perguntas `numeric` com o mesmo `pricingGroup` e um `pricingRole` cada:
`too_cheap`, `cheap`, `expensive`, `too_expensive`.
```jsonc
{ "id":"vw1","type":"numeric","text":"A que preço seria BARATO DEMAIS, a ponto de duvidar da qualidade?","pricingGroup":"g1","pricingRole":"too_cheap" }
{ "id":"vw2","type":"numeric","text":"A que preço seria uma pechincha?","pricingGroup":"g1","pricingRole":"cheap" }
{ "id":"vw3","type":"numeric","text":"A que preço começa a ficar caro?","pricingGroup":"g1","pricingRole":"expensive" }
{ "id":"vw4","type":"numeric","text":"A que preço seria CARO DEMAIS para comprar?","pricingGroup":"g1","pricingRole":"too_expensive" }
```

### 11.4 Preço — Gabor-Granger

Uma `single_choice` sim/não por preço, com `pricingMethod`, `pricingGroup` e `price`. O "sim"
tem `id` `"yes"`.
```jsonc
{ "id":"gg10","type":"single_choice","text":"Você compraria por R$ 10?",
  "pricingMethod":"gabor_granger","pricingGroup":"g1","price":10,
  "options":[{"id":"yes","label":"Sim"},{"id":"no","label":"Não"}] }
```

### 11.5 Kano

Por funcionalidade, **duas** `single_choice` na escala de 5 pontos (ids `"0"` a `"4"`), com
`kanoGroup`, `kanoFeature`, `kanoFeatureName` e `kanoSide` (`functional` / `dysfunctional`).
```jsonc
{ "id":"k1f","type":"single_choice","text":"Se o produto TIVER brinde, como você se sente?",
  "kanoGroup":"g1","kanoFeature":"brinde","kanoFeatureName":"Brinde","kanoSide":"functional",
  "options":[{"id":"0","label":"Gosto"},{"id":"1","label":"É o esperado"},{"id":"2","label":"Tanto faz"},{"id":"3","label":"Consigo conviver"},{"id":"4","label":"Não gosto"}] }
{ "id":"k1d","type":"single_choice","text":"Se o produto NÃO tiver brinde, como você se sente?",
  "kanoGroup":"g1","kanoFeature":"brinde","kanoFeatureName":"Brinde","kanoSide":"dysfunctional",
  "options":[{"id":"0","label":"Gosto"},{"id":"1","label":"É o esperado"},{"id":"2","label":"Tanto faz"},{"id":"3","label":"Consigo conviver"},{"id":"4","label":"Não gosto"}] }
```

---

## 12. O que NÃO usar na importação

Estes falham para quem responde. Os marcados com **(barrado)** o validador já recusa; os
outros passam nele. Use a alternativa.

| Não use | Por quê | Use |
|---|---|---|
| `"type": "text_block"` ou `"type": "image"` **(barrado)** | não aparece, e trava a página | `{"kind":"text"}` e `{"kind":"image"}` |
| `carryForward` na pergunta | a coleta ignora | `displayIf` por opção (10.1) |
| `expression_display` | só enxerga a própria página e grava valor | calculada + `{{calc.x}}` num texto |
| `{"all"}`, `{"any"}` **(barrado)** | quebra quem chega na condição | `and`/`or`, ou `op` |
| fonte do loop dentro do próprio bloco | o bloco some | fonte num bloco anterior |
| grupo de `columnGroups` ou região de `regions` escritos só como texto | o importador recusa | objetos (5.1) |
| matriz sem `columns` nem `scalePreset` **(barrado)** | página trava | declare as colunas |
| `{{...}}` em rótulo de opção/linha/coluna | aparece literal | uma pergunta por item, com o rótulo no enunciado |
| ids genéricos de cota (`c1`, `Q1`) | o contador colide com outros questionários | ids com o nome do estudo |

Nestes tipos, o limite não é cobrado do respondente: `total` de `constant_sum`/`budget_scale`,
`min`/`max` de `numeric_list` e `matrix_numeric`, o N de `rankTopN`, a faixa do `slider` (que já
abre com um valor marcado, então "obrigatória" não tem efeito nele). Se o limite importa, use
`numeric` com `min`/`max`, ou registre em `_pendencias`.

---

## 13. Códigos de erro do validador (e a correção)

| Código | Significa | Correção |
|---|---|---|
| `não entendi a condição perto de '…'` | sintaxe de `if` inválida | só `== != > >= < <= and or not ( ) count()` |
| `LOGIC_FORWARD_REFERENCE` | condição olha pergunta que ainda não apareceu | a pergunta citada vem **antes** |
| `LOGIC_UNKNOWN_QUESTION` | condição ou fórmula cita pergunta inexistente | confira o `id` (sorteio é `{var.x}`) |
| `CONDITION_MALFORMED` | condição que não é folha nem grupo (`{"all"}`, `"op": "AND"`) | forma curta (6.1) ou completa (6.2), `op` em minúsculas |
| `LOGIC_UNKNOWN_OPTION` | id de opção inexistente na condição | dê `id` fixo à opção e use o mesmo |
| `LOGIC_UNKNOWN_VARIABLE` | variável não declarada | declare em `variables` |
| `LOGIC_UNKNOWN_OPERATOR` | operador que não existe | lista da 6.2 |
| `UNKNOWN_QUESTION_TYPE` | `type` fora do catálogo | seção 4 |
| `ELEMENT_AS_QUESTION` | `"type": "text_block"` ou `"type": "image"` numa pergunta | `{"kind":"text"}` ou `{"kind":"image"}` |
| `MATRIX_WITHOUT_COLUMNS` / `MATRIX_WITHOUT_ROWS` | matriz sem colunas ou sem linhas | declare as duas listas |
| `FLOW_MALFORMED` / `FLOW_UNKNOWN_BLOCK` | nó do `flow` sem a chave de que precisa, ou bloco que não existe | 7 |
| `QUESTION_TYPE_WIP` (aviso) | tipo que ainda não roda | troque por um da seção 4 |
| `CHOICE_WITHOUT_OPTIONS` | pergunta de escolha sem opções | declare `options` |
| `DUP_QUESTION_ID` | dois elementos com o mesmo `id` | ids únicos |
| `UNKNOWN_ENDING` / `UNKNOWN_SKIP_TARGET` | aponta para encerramento/página/bloco que não existe | crie ou corrija o id |
| `EXPR_INVALID` | fórmula inválida, ou calculada sem fórmula | referências entre chaves `{id}` |
| `EXPR_UNDECLARED_VAR` (aviso) | fórmula cita `{var.x}` não declarada | declare em `variables`, ou confirme que vem na URL |
| `EXPR_UNKNOWN_OPTION` | rótulo citado na fórmula não existe | copie o rótulo exato |
| `CF_CYCLE` | calculadas em ciclo | quebre o ciclo |
| `VAR_NAME_INVALID` / `VAR_NAME_RESERVED` / `VAR_NAME_DUPLICATE` | nome de variável ruim | 8.1 |
| `VAR_NAME_FROM_URL` (aviso) | variável de painel declarada à toa | não declare `rid`, `pid`, `gid`, `psid`, `utm_*` |
| `FLOW_UNDECLARED_VAR` (aviso) | `set_variables` com variável não declarada | declare em `variables` |
| `DRAW_UNKNOWN_ENDING` | `whenEmpty: screenout` sem encerramento | `whenEmptyEndingId` + o encerramento |
| `DRAW_TOPN_MISSING` | `ranked_top_n` sem `topN` | declare `topN` |
| `QUOTA_SEM_CORTE` (aviso) | cota sem `quota_check` no fluxo | ponha o corte depois do perfil |
| `QUOTA_FORWARD_REFERENCE` | corte antes da pergunta da cota | mova o `quota_check` para depois |
| `LOOP_SINGLE_PASS` (aviso) | loop sobre resposta única com `selected` | `"pool":"all"` |
| `PIPE_UNKNOWN_REF` (aviso) | `{{ref}}` não existe | corrija a referência |
| `TRANSLATION_MISSING` (aviso) | falta texto num idioma | complete |
| `EMPTY_QUESTION_TEXT` / `EMPTY_OPTION_LABEL` / `DUPLICATE_OPTION` (aviso) | enunciado ou opção sem texto, opção repetida | escreva o texto; ids e rótulos únicos |
| `EMPTY_PAGE` (aviso) | página sem nada, que a coleta pula | tire a página, ou ponha as perguntas nela |
| `VALIDATION_KEY_INERT` (aviso) | chave de `validation` que o motor não lê (`numericMin`, `pattern`…) | o recado diz onde ela mora |
| `RANDOMIZATION_STRATEGY_INVALID` | `optionRandomization`, `rowRandomization` ou `columnRandomization` com estratégia que a coleta não conhece, ou `optionRandomization` que não é objeto: a página quebra na frente de quem responde | use `shuffle`, `rotation`, `reverse_random`, `alphabetical`, `subset_n`, `shuffle_groups` ou `groups_and_items` |
| `RANDOMIZATION_SHUFFLES_COLUMNS` (aviso) | matriz com `optionRandomization` e sem `columnRandomization`: as colunas (a escala) também saem em ordem sorteada | `columnRandomization: "as_is"` para sortear só as linhas |
| `RANDOMIZATION_INERT` (aviso) | `randomization` numa pergunta: a coleta não lê, e a ordem sai fixa | `optionRandomization` (na pergunta); `randomization` só vale no bloco, para a ordem das páginas |
| `ATTENTION_CHECK_SEM_GABARITO` (aviso) | armadilha de atenção sem `correctOptionId`, ou com um que não é alternativa: nunca conta na pontuação de qualidade | `correctOptionId` com o id da alternativa certa |
| `OPTION_KEY_INERT` (aviso) | campo em alternativa, linha ou coluna que ninguém lê (ex.: `anchor`, `value`): fica gravado e não faz nada | o recado diz o campo certo — `fixedPosition`/`allowText`/`exclusive` para posição, `code` para o código |

---

## 14. Exemplo completo

Estudo de café: apresentação → triagem (idade, profissão, marcas conhecidas) → perfil com cota
→ marca usada (herança) → avaliação da marca sorteada → NPS com motivo condicional.

```json
{
  "meta": { "title": "Hábitos de café 2026", "screenoutMode": "immediate" },
  "variables": [ {"name":"canal","type":"text","defaultValue":"site","exportable":true} ],
  "draws": [ { "name":"marca", "source":{"questionId":"conhece"},
               "exclude":"['outro','nenhuma']", "balance":true,
               "whenEmpty":"screenout", "whenEmptyEndingId":"filtro", "exportable":true } ],
  "blocks": [
    { "id":"B_intro", "pages":[ { "id":"P_intro", "elements":[
      { "kind":"text", "id":"T_intro",
        "html":"<b>Olá!</b><p>Esta pesquisa sobre café leva cerca de 5 minutos.</p>" } ] } ] },
    { "id":"B_filtro", "title":"Antes de começar", "pages":[
      { "id":"P_idade", "elements":[
        { "id":"idade", "type":"numeric", "text":"Qual é a sua idade?", "min":0, "max":120,
          "skipTo":[ {"if":"idade < 18", "target":{"kind":"ending","endingId":"filtro"}} ] },
        { "id":"setor", "type":"multi_choice", "text":"Você ou alguém da sua casa trabalha em alguma destas áreas?",
          "options":[ {"id":"pesquisa","label":"Pesquisa de mercado"},
                      {"id":"cafe","label":"Indústria de café"},
                      {"id":"nenhum","label":"Nenhuma destas","exclusive":true} ],
          "skipTo":[ {"if":"setor == 'pesquisa' or setor == 'cafe'",
                      "target":{"kind":"ending","endingId":"filtro"}} ] } ] },
      { "id":"P_conhece", "elements":[
        { "id":"conhece", "type":"multi_choice", "text":"Quais destas marcas de café você conhece?",
          "optionRandomization":{"strategy":"shuffle"},
          "options":[ {"id":"pilao","label":"Pilão"}, {"id":"melitta","label":"Melitta"},
                      {"id":"tres","label":"3 Corações"}, {"id":"nescafe","label":"Nescafé"},
                      {"id":"outro","label":"Outra","allowText":true},
                      {"id":"nenhuma","label":"Não conheço nenhuma","exclusive":true} ],
          "skipTo":[ {"if":"conhece == 'nenhuma'", "target":{"kind":"ending","endingId":"filtro"}} ] } ] } ] },
    { "id":"B_perfil", "title":"Sobre você", "pages":[ { "id":"P_perfil", "elements":[
      { "id":"sexo", "type":"single_choice", "text":"Com qual gênero você se identifica?",
        "options":[ {"id":"f","label":"Feminino"}, {"id":"m","label":"Masculino"},
                    {"id":"outro_g","label":"Outro"}, {"id":"na","label":"Prefiro não responder"} ] },
      { "id":"regiao", "type":"dropdown", "text":"Em qual região você mora?",
        "options":[ {"id":"se","label":"Sudeste"}, {"id":"s","label":"Sul"}, {"id":"ne","label":"Nordeste"},
                    {"id":"n","label":"Norte"}, {"id":"co","label":"Centro-Oeste"} ] } ] } ] },
    { "id":"B_main", "title":"Seu café", "pages":[
      { "id":"P_usa", "elements":[
        { "id":"usa", "type":"single_choice", "text":"Das marcas que você conhece, qual usa mais?",
          "options":[ {"id":"pilao","label":"Pilão","displayIf":"conhece == 'pilao'"},
                      {"id":"melitta","label":"Melitta","displayIf":"conhece == 'melitta'"},
                      {"id":"tres","label":"3 Corações","displayIf":"conhece == 'tres'"},
                      {"id":"nescafe","label":"Nescafé","displayIf":"conhece == 'nescafe'"},
                      {"id":"outra_u","label":"Outra marca"} ] } ] },
      { "id":"P_aval", "elements":[
        { "id":"aval", "type":"matrix_single", "text":"Sobre a marca {{draw.marca}}, o quanto você concorda?",
          "description":"Marque uma resposta por linha.",
          "scalePreset":"likert5_agree", "rowRandomization":{"strategy":"shuffle"},
          "rows":[ {"id":"r_sabor","label":"Tem um sabor que eu gosto"},
                   {"id":"r_preco","label":"Tem um preço justo"},
                   {"id":"r_confia","label":"É uma marca em que eu confio"} ] } ] },
      { "id":"P_nps", "elements":[
        { "id":"nps", "type":"nps", "text":"De 0 a 10, quanto você recomendaria {{draw.marca}} a um amigo?",
          "leftLabel":"Nada provável", "rightLabel":"Muito provável" },
        { "id":"motivo", "type":"text_long", "required":false, "text":"O que faria você dar uma nota maior?",
          "displayIf":{"if":"nps <= 6"} } ] } ] }
  ],
  "_quotas": [ { "id":"cafe2026_sexo", "name":"Sexo", "cells":[
    { "id":"cafe2026_sexo_f", "name":"Feminino", "target":150,
      "condition":{"source":{"kind":"question","questionId":"sexo"},"operator":"selected","operand":"f"} },
    { "id":"cafe2026_sexo_m", "name":"Masculino", "target":150,
      "condition":{"source":{"kind":"question","questionId":"sexo"},"operator":"selected","operand":"m"} } ] } ],
  "flow": [
    { "kind":"block", "blockId":"B_intro" },
    { "kind":"block", "blockId":"B_filtro" },
    { "kind":"block", "blockId":"B_perfil" },
    { "kind":"quota_check", "quotaId":"cafe2026_sexo" },
    { "kind":"block", "blockId":"B_main" },
    { "kind":"ending", "endingId":"fim" }
  ],
  "endings": [
    { "id":"fim", "status":"complete", "label":"Fim", "message":"Obrigado por participar!" },
    { "id":"filtro", "status":"terminated", "label":"Não elegível",
      "message":"Obrigado! Neste momento a pesquisa não segue para o seu perfil." },
    { "id":"cheia", "status":"overquota", "label":"Cota cheia",
      "message":"Obrigado! Já temos respostas suficientes do seu perfil." }
  ],
  "_pendencias": []
}
```

---

## 15. Checklist antes de entregar

- [ ] `meta.screenoutMode` é `"immediate"` (ou ausente, que dá no mesmo).
- [ ] Todo `type` está na seção 4, e nada da seção 12 foi usado.
- [ ] Toda pergunta, opção, linha e coluna citada em lógica tem `id` fixo, sem acento, hífen ou ponto.
- [ ] Nenhuma condição olha para pergunta que vem depois dela.
- [ ] Toda célula de cota tem id com o nome do estudo.
- [ ] Todo `endingId`, `pageId` e `blockId` citado existe; a triagem aponta para um encerramento `terminated`.
- [ ] Quem tem menos de 18 anos é encerrado, se houver pergunta de idade.
- [ ] Marcas de programação foram tiradas dos enunciados; "Outro" tem `allowText`, "Nenhuma" tem `exclusive`.
- [ ] O que não coube no USL está em `_pendencias`.
