Synsemadocsv0.6.xENES

LLM

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.

judge.syn
-- 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)
        assert_eq(v.team.probabilities, {})                    -- no distribution to read offline
        assert_eq(get(v.team.probabilities, "none"), nothing)  -- so read a key with get()

test "any word parses after a dot (v0.6.29+), but the field is kind — there is no type"
    assert_error(() => v.team.type)
    assert_eq(v.team.kind, "choose")

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§

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:

VerboPreguntaDistribuciónCampos de la respuesta
whether "…"¿es cierta esta afirmación?Bernoulliprobability (0..1)
choose "…" between {…} [or nothing]¿cuál de estas?categórica, sin ordenchoice, probabilities, confidence
rate "…" across […]¿dónde en esta escala ordenada?ordinalscore, 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}
v.anger.confidence      -- 0..1

confidence of v.team también funciona. El campo es kind, no type, y la clave de escape es none, no nothing — nombres elegidos cuando esas palabras reservadas no podían ir después de un .. Desde v0.6.29 cualquier palabra parsea después de un ., así que v.team.type carga, y falla al correr con Map has no key 'type'.

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:

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(get(v.anger.probabilities, "upset"))    -- get(): offline the map is empty

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:

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).

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 not w.team.available
    print("judge offline — a person decides")
otherwise 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; probabilities es un map vacío {} (un rate conserva sus levels). Un aviso por stderr; el programa sigue. Así que leé una probabilidad a prueba de offline con get(v.team.probabilities, "none"), o chequeá available antes — v.team.probabilities.none sobre una respuesta offline es Map has no key 'none'.

Adentro de parallel_map y de un agente lanzado con spawn (motor v0.6.45+), judge usa el mismo provider que el código que los lanzó, y SYNSEMA_JUDGE_BUDGET cuenta ahí también. Antes, bajo synsema run cada item de parallel_map y cada agente corrían en un intérprete nuevo sin providers, así que judge contestaba offline (available: false) ahí mientras el mismo código en el hilo principal recibía respuestas reales. Bajo serve, los agentes ya estaban cableados.

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, y lo mismo el que pega la elección en un texto: desde v0.6.29 "route to " + v.team.choice con una elección nothing es Cannot add text and nothing — cuidá el escape, o interpolá (` route to {v.team.choice} ). judge_available() dice si hay un provider cableado; available` en la respuesta es por llamada.

Correrlo local: SYNSEMA_JUDGE_PROVIDER=laya (v0.6.27+)§

El slot del juez es un protocolo, no un vendor. Desde v0.6.27 los binarios oficiales traen un segundo backend: un checkpoint de Laya (ModernBERT) en disco contesta whether, choose y rate sin red, sin secreto y sin costo por token. El bloque de arriba corre igual — lo único que cambia es la configuración:

SYNSEMA_JUDGE_PROVIDER=laya
SYNSEMA_JUDGE_MODEL=/models/laya      # el directorio del checkpoint, o un org/repo ya en el cache de HF

No se descarga nada por vos: el checkpoint pesa ~843 MB y bajarlo es una decisión del operador, igual que con un .gguf. Si no está, el error dice cómo conseguirlo. Corre sobre la misma capa de inferencia que el proveedor LLM local — ver Inferencia local.

Lo que esto cambia es que estar offline deja de ser un destino y pasa a ser una elección. Un programa con require judge, deny net y sin ningún secreto igual obtiene probabilidades calibradas. Medido sobre el ticket del principio de esta página (cuatro preguntas, un bloque, CPU):

refund.probability0,8596
team.choice / confidencebilling / 0,3595 → la compuerta < 0.8 manda correctamente a un humano
anger.level / scorefurious / 1,7154
choose … or nothing sobre un mensaje fuera de temanothing con 0,8646
reloj de pared8,8 s el programa entero, carga del modelo incluida
determinismo3/3 corridas idénticas

judge_model() devuelve laya:<checkpoint>, así que dos corridas con pesos distintos nunca se confunden en un log. SYNSEMA_JUDGE_BUDGET sigue contando tokens de entrada, pero no hay factura detrás de ellos.

Dicho en voz alta, porque de otro modo es invisible: la secuencia que ve el modelo es token a token idéntica a la implementación de referencia upstream (hay un test permanente que lo afirma), pero la paridad numérica con el upstream sigue abierta — sobre el caso publicado upstream obtenemos el mismo ganador con otra confianza (0,864 contra 0,94). Tratá los números locales como los números de este motor, y afirmá rangos en vez de valores.

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) — salvo que el host permita ese principal para el proveedor del juez (sección siguiente).

Datos privados al juez§

Motor v0.6.46+, con --labels. Un estado private puede llegar al juez cuando lo dicen los dos lados: el programa declara de quién son los datos que puede mandar con require judge("customers"), y el techo del host nombra ese principal y el proveedor del juez:

SYNSEMA_JUDGE_PROVIDER=laya SYNSEMA_JUDGE_MODEL=/models/laya \
  synsema run --labels --cap-set "stdout,judge=customers@local,declassify=customers" app.syn
require judge("customers")
let ticket be private({"text": "I want my money back"}, "customers")
let v be judge ticket
    refund: whether "The customer is asking for money back"
let to_billing be declassify(v.refund.probability > 0.8, "whether to route to billing is public")
Ítem del techoDeja que los datos privados de customers vayan a
judge=customers@localun juez en el proceso: laya, y mock
judge=customers@api.typesafe.aiel juez hosteado (con SYNSEMA_JUDGE_BASE_URL puesto, el host de esa URL)
judge=customers@*cualquier proveedor de juez — escrito a propósito
judge=customers, judgeningún dato privado: un ítem sin @proveedor nunca lo permite
llm=customers@…no el juez: los dos slots tienen ítems separados

Decidí con ellas a través de un declassify, que tiene su propio ítem del techo (declassify=customers).

desnudo (sólo datos públicos) o no hay techo: `label_violation: judge would send data private to (customers) to the model; the host allows that per principal and provider in its ceiling (--cap-set judge=<principal>@<provider host>, @local for a model in the process, @embedder in wasm) and the program declares require judge("<principal>"). Otherwise declassify(<that value>, "<why the model may see it>") the part the model may see (el detalle, motor v0.6.47+: bajo un techo el programa ve el error fijo de Qué muestra un corte). Un bloque judge` bajo una rama privada se rechaza antes que nada y, desde v0.6.46, no escribe entrada de auditoría.

judge= — el programa necesita require judge("customers") — y, como cae al modelo cuando el juez no elige, también necesita llm, en el programa y en el techo: --cap-set "stdout,llm,judge=customers@local,declassify=customers". Sin eso la corrida se frena antes de la llamada, en su línea (motor v0.6.49+; antes, un error redactado a private(customers)): `Capability not granted: llm — a decide served by the judge (SYNSEMA_JUDGE_DECIDE) falls back to the model when the judge does not choose, so it needs llm as well as judge: add require llm to the program's preamble (with private data, require llm("<principal>")), and the host's ceiling has to allow it too (llm, or llm=<principal>@<provider host> for private data), or unset the knob. El chequeo corre antes de evaluar el given; un decide cuya sentencia tocó el valor privado antes ([c + "!", decide … given c]`) igual recibe el texto redactado, así que dale su propia línea.

el proveedor ve el estado, y su respuesta es privada pero no confiable.

Configuración§

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

KnobParaDefault
TYPESAFE_API_KEYla clave; su presencia también selecciona el provider typesafe— (offline si falta)
SYNSEMA_JUDGE_PROVIDERtypesafe | laya (LOCAL, ver abajo) | mock (respuestas deterministas, sin red: tests y demos)auto por la clave
SYNSEMA_JUDGE_MODELid o alias del modelo; con laya, el directorio del checkpoint (o un org/repo que ya esté en el cache de Hugging Face)jev-latest
SYNSEMA_JUDGE_BASE_URLbase del endpoint; cualquier host que sirva el mismo cablehttps://api.typesafe.ai
SYNSEMA_JUDGE_TIMEOUTtimeout HTTP en segundos60
SYNSEMA_JUDGE_BUDGETtecho 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. Cada backend muestra sólo lo que le aplica — typesafe: clave, modelo, base URL y timeout; laya: el checkpoint; mock: nada de eso. 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. También exige llm, por la caída de abajo (desde v0.6.49 el motor lo dice en el decide). 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 choose 0,59/0,41 con confianza 0,17; dos whether dieron 0,99 y 0,99.

0,88; la confianza no delata la indistinción. Tres niveles concretos: 1,00.

el whether sobre la misma frase dio 0,38, honesto. Dale a la incertidumbre un lugar adonde ir.

bien; un total de seis líneas salió mal a 0,32 con confianza media. Calculá, después juzgá.

1,40 apareció sólo con sarcasmo, a confianza 0,40.

0,97 / 0,93 / 0,99).

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.

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.

Del backend local en particular: su act_head queda afuera a conciencia (es una rama paralela que no toca los logits — la respuesta es idéntica con o sin él, y judge no expone una probabilidad de acción), y corre en f32 aunque el checkpoint sea f16, porque en CPU la media precisión se emula y sale más lenta.