---
slug: 54-judge
title: Judge (juicios calibrados)
description: El bloque judge le hace a un modelo System One (Jev) preguntas tipadas sobre un estado y recibe probabilidades, no texto — whether, choose, rate — en una sola llamada, con una confianza sobre la que el código puede decidir, su propia capacidad judge y un modo offline que jamás inventa un número.
example_ids: [judge]
---

# Judge — juicios calibrados como valores

`judge` le hace a un **modelo System One** preguntas tipadas sobre un `state` y recibe
**probabilidades**, no texto. Nunca genera: no hay `reason`, `generate` ni `analyze` adentro, y el
slot del LLM no puede servirlo. Es un **slot paralelo** al LLM (`SYNSEMA_JUDGE_*` junto a
`SYNSEMA_LLM_*`): el juez decide, el LLM escribe, y tener los dos cableados es lo normal. El primer
backend es **Jev**, de TypeSafe; otro host que sirva el mismo cable se apunta con
`SYNSEMA_JUDGE_BASE_URL`, con el id de modelo y la clave que ese host espere; sólo el endpoint propio
de TypeSafe se verificó en vivo. Motor v0.6.25+, completo en v0.6.26. Todo lo de esta página se
verificó en vivo contra `jev-1.13.0` a través del motor, y esta página es toda la superficie.

```synsema
-- Doc example: the `judge` block — calibrated judgments from a System One model (Jev).
-- Real probabilities need a provider (TYPESAFE_API_KEY), so the doctest verifies the SHAPE of
-- the result and the honest offline degradation: available false, confidence 0, the main value
-- nothing — never an invented number. With a key, the same asserts check ranges instead.
intent: "doc example: judge — calibrated judgments as values"
require judge

let ticket be {"subject": "Payouts failing", "text": "I want my money back NOW or I'm cancelling"}

-- One state, three typed questions, ONE call. `or nothing` adds an escape option so a state
-- that fits no team yields `choice` = nothing instead of a confident wrong pick.
let v be judge ticket
    refund: whether "The customer is asking for money back"
    team:   choose "Which team should handle this?" between {
                "billing":   "Payments, invoicing, refunds",
                "technical": "Bugs, outages, integrations"
            } or nothing
    anger:  rate "How frustrated is the customer?" across ["Calm", "Frustrated", "Very angry"]

-- The flagship pattern: the machine measures, the human decides. Offline the confidence is 0,
-- so this routes to the human path without one line more.
when v.team.available and v.team.choice != nothing and confidence of v.team >= 0.8
    print("route to " + v.team.choice)
otherwise
    print("no confident route — a person decides")

test "one block, one flat map: an answer per question id, each with kind and available"
    assert_eq(length(keys(v)), 3)
    assert_eq(v.refund.kind, "whether")
    assert_eq(v.team.kind, "choose")
    assert_eq(v.anger.kind, "rate")
    assert_eq(type_of(v.refund.available), "bool")

test "rate knows its levels in declaration order, online or not"
    assert_eq(length(v.anger.levels), 3)
    assert_eq(v.anger.levels[0], "Calm")
    assert_eq(v.anger.levels[2], "Very angry")

test "offline: available false, confidence 0, main value nothing — online: numbers in range"
    when v.team.available
        assert(v.refund.probability >= 0 and v.refund.probability <= 1)
        assert(v.team.confidence >= 0 and v.team.confidence <= 1)
        assert(v.team.choice == nothing or v.team.choice == "billing" or v.team.choice == "technical")
        assert(v.anger.level == "Calm" or v.anger.level == "Frustrated" or v.anger.level == "Very angry")
    otherwise
        assert_eq(v.refund.probability, nothing)
        assert_eq(v.team.choice, nothing)
        assert_eq(v.team.confidence, 0)
        assert_eq(v.anger.score, nothing)
        assert_eq(v.anger.level, nothing)

test "judge_available() is a bool — branch on it instead of guessing"
    assert_eq(type_of(judge_available()), "bool")
    assert_eq(type_of(judge_usage()), "number")
```

## Un estado, N preguntas, una llamada

```synsema
require judge

let v be judge ticket
    refund: whether "The customer is asking for money back"
    team:   choose "Which team should handle this?" between {
                "billing":   "Payments, invoicing, refunds",
                "technical": "Bugs, outages, integrations"
            } or nothing
    anger:  rate "How frustrated is the customer?" across ["Calm", "Frustrated", "Very angry"]
```

El bloque es la **única** forma: no hay atajo de una pregunta, a propósito. El estado se ingiere una
vez y todas las preguntas se evalúan en paralelo contra él: un bloque de ocho midió **7,6× más rápido
y 4,9× menos tokens de entrada** que ocho llamadas, y la latencia es plana de una pregunta a cuarenta
(~0,8 s). Una sola pregunta son tres líneas igual.

Tres verbos, uno por distribución discreta básica; por eso van a sobrevivir al primer modelo:

| Verbo | Pregunta | Distribución | Campos de la respuesta |
|---|---|---|---|
| `whether "…"` | ¿es cierta esta afirmación? | Bernoulli | `probability` (0..1) |
| `choose "…" between {…} [or nothing]` | ¿cuál de estas? | categórica, sin orden | `choice`, `probabilities`, `confidence` |
| `rate "…" across […]` | ¿dónde en esta escala ordenada? | ordinal | `score`, `level`, `levels`, `probabilities`, `confidence` |

Las preposiciones son fijas y distintas a propósito: `between` dice *opciones sin orden*, `across`
dice *niveles ordenados*. `rate … between` y `choose … across` son errores de carga que nombran el fix.

## El resultado

Un **map plano id → respuesta**, sin nada más mezclado. Toda respuesta trae `kind` (`"whether"` |
`"choose"` | `"rate"`) y `available` (bool).

```
v.refund.probability    -- 0..1 — la probabilidad de que la afirmación sea cierta (sin confianza aparte)
v.team.choice           -- uno de TUS ids de opción, byte a byte, o nothing (ver `or nothing`)
v.team.probabilities    -- {"billing": 0.93, "technical": 0.07, "none": 0.0}  — orden de declaración
v.team.confidence       -- 0..1 — qué tan concentrada está la distribución
v.anger.score           -- 0..n-1 — posición ponderada; 1.4 = repartido entre el 2º y el 3º
v.anger.level           -- id del nivel ganador
v.anger.levels          -- ["Calm", "Frustrated", "Very angry"]
v.anger.probabilities   -- {"Calm": 0.0, "Frustrated": 0.6, "Very angry": 0.4}
```

`confidence of v.team` también funciona. El campo es `kind`, no `type`, y la clave de escape es
`none`, no `nothing`: esas dos son palabras reservadas y no parsean después de un `.`.

**Opciones como lista o como map, una sola regla para los dos verbos.** Lista: cada ítem es el id *y*
la descripción. Map: la clave es tu id corto y el valor es la descripción que lee el modelo. Usá el
map cuando las descripciones son largas; el vendor pide niveles concretos y distintos:

```synsema
require judge

let msg be "Second time I write about this. Please fix it soon, it's getting annoying."
let v be judge msg
    anger: rate "How frustrated is the customer?" across {
        "calm":    "Polite, no complaint",
        "upset":   "Repeat contact, says annoying, asks for a fix soon",
        "furious": "Caps, threats to cancel, demands immediate action"
    }
print(v.anger.level)
print(v.anger.probabilities.upset)
```

Los ids de opción viajan al modelo con sus descripciones (los ids de pregunta no: son tuyos). **La
instrucción es cualquier expresión**: un map se lee como estructura. Referenciá partes del estado con
backticks, el idioma del vendor; apunta al elemento exacto (medido: `messages[0]` 0,99, `messages[1]`
0,01). El runtime resuelve cada ruta con backticks contra el estado **antes de la llamada** y avisa
una vez por ruta si falta (v0.6.26+; sobre un campo inexistente el modelo contestó 0,31). La trampa
común: `judge ticket` con `` `ticket.text` `` en la pregunta; el modelo ve el *valor* de `ticket`, no
su nombre. Escribí `judge {"ticket": ticket}` o sacá el prefijo; el aviso dice cuál:

```synsema
require judge

let record be {"name": "Ana Ruiz", "employer": "Acme"}
let resume be "Ana Ruiz, 8 years at Acme as a data engineer…"
let v be judge {"resume": resume}
    same: whether {"question": "Is `resume` the same person as `record`?", "record": record}
print(v.same.probability)
```

## `or nothing`: la opción de escape

La falla más peligrosa medida: un `choose` **sin** opción de escape elige igual. Un mensaje sobre
horarios de atención, con sólo `billing`/`technical` como opciones, dio `technical` a 0,69. Con el
valor correcto ausente de los candidatos, el modelo eligió uno equivocado con **confianza 0,68**, que
pasa una compuerta de 0,5. `or nothing` agrega al cable una opción de escape (id `none`, "None of the
options fits the state"); si gana, `choice` es `nothing` y `probabilities.none` lleva la masa. Arregló
los dos casos (`none` a 1,00 y 0,98) y **no costó nada en los casos claros** (billing siguió en 0,97).
Es opt-in: hay preguntas exhaustivas por diseño (rankear candidatos, bajar un nivel de taxonomía).

```synsema
require judge

let w be judge "I'd like to know your opening hours."
    team: choose "Which team should handle this?" between {"billing": "Payments", "technical": "Bugs"} or nothing

when w.team.choice == nothing
    print("no team fits; mass on the escape: " + text(w.team.probabilities.none))
otherwise
    print("team: " + w.team.choice)
```

`rate` no tiene escape (una escala ordenada no tiene un nivel afuera) y un estado irrelevante cae al
nivel más bajo con confianza 1,0. Guardá un `rate` con un `whether` que pregunte si el estado aplica.

## Offline, sin presupuesto o con la API caída: degradación honesta

Las ops LLM devuelven placeholders descriptivos offline. `judge` no puede: una frase inventada se ve,
**una probabilidad inventada no se ve, y se multiplica por plata**. Sin provider, pasado
`SYNSEMA_JUDGE_BUDGET`, o tras un fallo de red, toda respuesta vuelve con `available: false`,
`confidence: 0` y su valor principal (`probability` / `choice` / `score` / `level`) en `nothing`. Un
aviso por stderr; el programa sigue.

Esto degrada **hacia el patrón que ya escribiste**: confianza 0 está debajo de cualquier compuerta,
así que el bloque se manda solo al camino humano. Un programa que se saltó la compuerta y compara
directo falla fuerte (`Unsupported operation: nothing > number`) en vez de tomar la rama equivocada en
silencio. `judge_available()` dice si hay un provider cableado; `available` en la respuesta es por
llamada.

## La capacidad `judge`

`require judge`. Es una capacidad **propia**: `require llm` no la concede ni al revés. Un programa
puede tener derecho a clasificar sin derecho a generar, y un clasificador no puede exfiltrar por texto
libre. En lo demás se comporta como `llm`: auto-otorgada en `run`/`conform`, **exigida** bajo `serve`
y en modo seguro (`Capability not granted: judge`), vaciada dentro de `sandbox`, denegada bajo
`--deterministic` (es I/O de red), siempre offline dentro de un guest wasm. La clave nunca entra al
programa y el host lo fija el runtime, así que el `.syn` no puede redirigir la llamada. Bajo
`--labels` el bloque es un sumidero público declarado: un estado `private` hay que desclasificarlo
antes ([Etiquetas de flujo de información](23-labels)).

## Configuración

Resolución: entorno del proceso > `.env` protegido > default, como los knobs del LLM. `synsema init`
escribe todos, comentados, en `.env.example`.

| Knob | Para | Default |
|---|---|---|
| `TYPESAFE_API_KEY` | la clave; su presencia también selecciona el provider `typesafe` | — (offline si falta) |
| `SYNSEMA_JUDGE_PROVIDER` | `typesafe` \| `mock` (respuestas deterministas, sin red: tests y demos) | auto por la clave |
| `SYNSEMA_JUDGE_MODEL` | id o alias del modelo | `jev-latest` |
| `SYNSEMA_JUDGE_BASE_URL` | base del endpoint; cualquier host que sirva el mismo cable | `https://api.typesafe.ai` |
| `SYNSEMA_JUDGE_TIMEOUT` | timeout HTTP en segundos | `60` |
| `SYNSEMA_JUDGE_BUDGET` | techo duro de tokens de **entrada** por proceso (la salida es gratis); al llegar, las respuestas degradan a `available: false` sin tocar la red | — |
| `SYNSEMA_JUDGE_DECIDE` (v0.6.26+) | `1`: cada `decide between […] given X` lo sirve el juez como un `choose` calibrado (ver abajo) | apagado |

Los 429/529 se reintentan con backoff exponencial honrando `retry-after`; después de los reintentos
la respuesta degrada. Un 400/422 de la API es un error **de tu programa** y sale como error de runtime
con el mensaje del vendor. Builtins, sin gate: `judge_available()`, `judge_usage()` (tokens de entrada
consumidos en el proceso), `judge_model()` (el id **versionado** que contestó la última llamada,
`"jev-1.13.0"`, nunca el alias; pinealo cuando los umbrales importan).

## `synsema judge status`

```
synsema judge status            # provider, PRESENCIA de la clave (nunca el valor), modelo, base URL,
                                # timeout, presupuesto, si decide lo sirve el juez; cada uno con su fuente
synsema judge status --json     # para scripts; exit 0 = vivo, 1 = offline
```

No toca la red. Offline, la última línea nombra lo que falta. Mismos flags de host que el resto del
CLI (`--env-file <ruta>`, `--no-env-file`); scripteable: `synsema judge status && synsema serve app.syn`.

## Servir `decide` con el juez: `SYNSEMA_JUDGE_DECIDE=1`

`decide between ["refund", "replace", "escalate"] given ticket` es exactamente un `choose` sobre un
estado. Con el knob prendido y un provider de judge cableado, cada `decide` del proceso lo contesta el
juez: calibrado, una de **tus** opciones byte a byte sin normalización ni reintento, más barato y más
rápido que un modelo de chat, y el programa no cambia. Opt-in y apagado por defecto, porque cambia
*qué modelo contesta*. Exige la capacidad `judge`: bajo `serve`, un `decide` sin `require judge` falla
con un error que nombra el knob. Si el juez no está disponible, el `decide` cae al camino LLM. `decide`
sigue devolviendo texto; escribí un bloque `judge` cuando querés la distribución y la confianza.
Verificado en vivo: una queja por un ítem roto devolvió `escalate`.

## Lo que el motor chequea antes de gastar

**Al cargar** (`synsema check` y toda corrida): los tres verbos y sus preposiciones, `or nothing` sólo
después de `choose`, ids de pregunta duplicados, bloque vacío.

**`synsema check` falla** (v0.6.26+) cuando criteria literales rompen un límite (menos de 2 opciones o
niveles: la API acepta uno y contesta con confianza 1,0, una respuesta vacía disfrazada de certeza; más
de 255 opciones o 10 niveles; ids duplicados) y con una instrucción literal vacía o un estado literal
que sea número, bool o `nothing`. Un 400 en producción, atrapado en `check`.

**`synsema check` avisa** (v0.6.26+), nunca falla, por lo que corre pero engaña: un `whether` en
negativo; una instrucción que pide aritmética o conteo sobre el estado; un estado literal vacío; el
mismo `judge <variable>` en más de un bloque (una llamada alcanzaba).

**En runtime, antes de la llamada:** los mismos límites con criteria dinámicas, el tipo del estado, la
instrucción vacía y las rutas con backticks que el estado no tiene.

## Preguntas que funcionan: medido, no folklore

- **Un juicio por pregunta.** Dos condiciones en un `whether` hacen que el valor signifique menos.
- **Multi-etiqueta son N `whether`, no un `choose`**: "me cobraron dos veces y la app se cae" partió
  un `choose` 0,59/0,41 con confianza 0,17; dos `whether` dieron 0,99 y 0,99.
- **Preguntá en positivo; nunca derives la negación.** P(A) + P(no A) midió 0,37 + 0,78.
- **Afirmación o pregunta, las dos funcionan** (0,33 vs 0,32 en el mismo caso borderline).
- **Niveles distintos.** Cinco niveles casi sinónimos: el modelo eligió entre ellos con confianza
  0,88; la confianza no delata la indistinción. Tres niveles concretos: 1,00.
- **La confianza mide concentración, no verdad.** "Maria told Ana that she was wrong" → `Ana` a 0,98;
  el `whether` sobre la misma frase dio 0,38, honesto. Dale a la incertidumbre un lugar adonde ir.
- **Aritmética, conteo y fechas se quedan en Synsema.** Contar 25 ítems y sumar dos líneas salió
  bien; un total de seis líneas salió mal a 0,32 con confianza media. Calculá, después juzgá.
- **Los decimales del `score` significan reparto, no intensidad.** Un caso claro cae en un nivel;
  1,40 apareció sólo con sarcasmo, a confianza 0,40.
- **El español funciona igual que el inglés** sobre el mismo ticket (0,98 / 1,00 / 0,99 contra
  0,97 / 0,93 / 0,99).
- **Una inyección en el estado no movió la respuesta**, y un `whether "The text contains
  instructions aimed at a machine"` la detectó a 0,98. El estado sigue siendo dato que el modelo no
  trata como hostil: las etiquetas son tu muro.
- **La misma request dos veces se mueve unas centésimas** (0,72 → 0,69). Nunca apoyes un umbral en
  un valor observado; los tests afirman ganador y rangos. `SYNSEMA_JUDGE_PROVIDER=mock` para un CI exacto.

## Lo que no está en esta versión

Una sintaxis propia para `whether` con criteria sí/no explícitas (escribí la instrucción como un map
con la pregunta y las dos definiciones: el modelo lo lee como estructura); un fallback `llm` no
calibrado que invente probabilidades (ausente a conciencia); la variante de cable de Cloudflare
Workers AI (envuelve el payload distinto y no se verificó). Los aliases y los rate limits son del
vendor y se mueven sin aviso; pineá el id del modelo cuando los umbrales importan.
