Skip to main content

Luna — arnés, evals y trazas

Cuatro cosas: cómo está construido un turno, qué mide el bench y por qué, una traza real completa, y qué dicen los seis benches leídos como capas. Todo número viene de bench-N.json o del código. Donde no hay medición, dice no medido.

1. El arnés: qué pasa en un turno

LunaTurnService.runTurn (apps/api/src/luna/luna-turn.service.ts), en orden. 1 · Preflight. Tres puertas, orden fijo (§7): rollout.visibleToentitlements.assertFeature(workspace,'assistant')usage.preflight con requireCredits=false, spendsCall=false — un turno es gratis; los créditos los cobra la decisión. Cualquiera devuelve turn.refused con código tipado y su HTTP fijo, sin llegar al modelo. 2 · Compilar la entrada. LunaInputCompilerService.compile arma el TurnInput de §5.1 desde perfil comercial y cobertura (caché 10 min) más el snapshot. El libro de hechos se fusiona en tres capas: perfil → sesión (snapshot.facts, siempre gana) → criterio de ESTE mensaje (luna-criterion.ts, solo rellena huecos). Hilo recortado a 12 turnos. Real, para el workspace golden 0000…0001 y el brief comparar-segmentos-acero (su perfil está vacío: los seis ejes compilan faltante, verificado porque brief-vago-clientes-nuevos abre en sin_criterio):
La etapa se deriva después de compilar, con stageFor(snapshot, compiled.facts): un solo hecho dicho abre Buscando. Es lo que emite turn.accepted en el dump real. 3 · Manual por etapa. buildLunaSystem(input). Presupuesto 14 000 chars, piso fijo 12 341. Aquí el prompt mide 12 857: El presupuesto solo toca contexto, en este orden: hilo → memoria → cobertura → filas opcionales del perfil. Una regla nunca se recorta; si el piso no cupiera, el módulo revienta al importar. 4 · Cinturón visible. 11 herramientas en total, proyectadas por etapa (§4.10, tope 12): Ninguna escribe. luna-boundary.spec.ts escanea el texto de src/luna/ en CI buscando unlockContacts, debitAndRun, promote( — el candado de la eval 1. 5 · Sesión gestionada. Una ManagedSession por turno, superficie luna-chat, sessionId = runId, primario DeepSeek V4 Flash. Se pide json_schema de entrada (no se paga un viaje extra para llegar al mismo modo), con allowTechnicalFailover: true. Solo un rechazo no_capable_model degrada a json_object y arma la escalada. En los cinco benches: 0 escaladas, 100 % primario. 6 · El bucle. maxSteps: 8, stubToolResultsOverChars: 4 500, deadlineMs: 150 000 (subido de 90 000 tras bench 5). Cada resultado pasa por un cosechador que camina el JSON (profundidad ≤ 8, ≤ 200 elementos por arreglo) y saca tres cosas:
  • anclas: el campo ref estampado por el backing ("run:mag-2") gana sobre cualquier heurística de campo;
  • nombres: ref_label, legal_name, razon_social… — lo que el lector verá donde estaba el token;
  • cifras: solo números JSON y strings que son enteramente una cifra o una fecha ISO, nunca por regex sobre el texto (un id tiene dígitos y exentaría números inventados). Tope 500 por turno; al desbordarse el gate vuelve a estricto, que falla cerrado.
7 · Validación. Seis reglas, con su etiqueta estable (luna-intent.ts): El set conocido = anclas cosechadas + selección + runs + decisiones pendientes. Un fallo produce una instrucción de reparación que cita el problema y la regla y nunca sugiere contenido, y se gasta una. Literal de la plantilla de fail():
8 · Resolución, fold y eventos. stripRefTokens cambia cada ancla por su nombre; un run: resuelve a cadena vacía (la cifra ya está en la frase) y tidySpacing cierra el hueco. El fold escribe hilo, parche de criterio y tarjeta pendiente, y vuelve a derivar la etapa. Eventos: turn.acceptedtool.started/tool.finished (n veces) → narration.deltadecision.proposed (si aplica) → turn.finished; o turn.refused. 9 · Costo. recordSession corre en finally, siempre, incluso si el turno explotó: un libro que solo se escribe en el camino feliz miente en la dirección que duele. cost_micros es null si algún intento quedó sin precio — nunca un cero fabricado.

2. La matriz de evals

Los 20 briefs

apps/api/src/luna/bench/briefs.json. Distribución esperada: 12 cierre, 5 limite, 2 responder, 1 encuadre. 18 con forbid_question, 4 con must_count, 3 con falta_tipo, 3 con seed.

Los 8 medidores que el bench aplica a cada brief

Además, must_count (evento con label Contando el universo real), forbid_question, falta_tipo y “refusal ⇒ fail” entran al veredicto ok. Los errores de herramienta NO son un medidor del bench: no hay columna, los conté sobre los tool.finished de cada dump.

Los 8 evals bloqueantes de §9


3. Una traza real, completa

comparar-segmentos-acero, bench 6 (2026-09-10T05:36:09Z, manual v2.1.0, primario). Tres herramientas, una reparación. Turno fb3bab6b-5c4c-4b2a-83ad-a78e9e67fda3.
Sobre el “chain of thought”: no existe en disco, por diseño. §6 dice que el razonamiento se muestra en vivo si el proveedor lo emite y nunca se persiste; el perfil del primario además desactiva thinking en superficies de tool-calling (cuesta tarifa de salida y alarga el ttft). onStep recibe los deltas y los tira al piso a propósito. Lo observable es la secuencia de llamadas, abajo.
Lo que Luna ve — el brief:
Compara el segmento de estructuras de acero contra el de herrería industrial: quién compra más, a qué precio promedio y cuál tiene menos competencia.
Lo que el servidor decidecriterionFromMessage extrae giro = dicho "estructuras de acero"; los seis ejes del perfil quedan faltante; stageFor deriva buscando (el evento turn.accepted lo confirma) y con ello un cinturón de 11 herramientas. Lo que Luna ve — la sección del manual que aplicó (## Ahora: buscando, 870 chars, íntegra):
Lo que Luna escribe — las tres llamadas, en el orden en que arrancaron: Los argumentos exactos: no medidos. tool.finished solo lleva {label, summary, refs, reads_counted} y el summary es un preview de 80 caracteres; el dump no persiste argumentos ni payload completo. Es un hueco real de observabilidad. Lo que sí se sabe: refs_available = 3, y la tercera llamada (herrería industrial) volvió vacía. Lo que Luna escribe — primer intento, verbatim (recortado a lo esencial):
Lo que el servidor decide — veredicto del validador:
Una sola cifra de veintitantas. 107 505 080.10 / 23 = 4 674 134: la división es correcta, pero el resultado no está en ningún payload, así que no entró al set de cifras conocidas y el gate lo rechaza. El resto —51 empresas, 23 contratos, el total— pasó. Lo que el servidor decide — la reparación enviada: plantilla de fail(), una regla citada, cero sugerencia de contenido (bloque literal en la sección 1, paso 7). Lo que Luna escribe — segundo intento, verbatim, completo:
Aceptado — y este es el hallazgo de la traza: el validador es un piso, no un techo. Ante una crítica sobre una cifra derivada el modelo tiró la respuesta entera y entregó un párrafo vacío que no rompe ninguna regla: ok: true, unresolved_refs: 0, cifra limpio. El bench lo marca FAIL intent=responder≠cierre únicamente porque el medidor de intent lo cazó; sin él, un turno bueno degradado a nada habría pasado como éxito. La narración final es idéntica al segundo intento: no llevaba ningún token que resolver. Eventos, en orden: turn.accepted{stage:"buscando"}tool.started×3 → tool.finished×3 (refs:[run:mag-2], [run:mag-3], [run:mcp-1]) → narration.deltaturn.finished{intent_kind:"responder"}. Sin decision.proposed: nada que decidir. Números: 3 steps · 3 tool calls · 1 reparación · $0.02270 · ttft 949 ms · duración 31 070 ms · 0 refs sin resolver.

4. Los seis benches, leídos como capas

Solo hay cinco dumps (bench-2bench-6); bench 1 no dejó archivo. Común a los cinco: 100 % primario, 0 escaladas, ttft p50 entre 924 y 1 113 ms. La duración máxima subió a 132 s en bench 6 — dentro del deadline nuevo, pero apenas.

Qué falta para congelar S1

  1. Cifras derivadas. 5 de 6 defectos de bench 6 y los 2 rechazos son promedios calculados a mano. O el backing devuelve el promedio con su ref, o el gate acepta una cifra derivada de cifras conocidas. Hoy la regla castiga aritmética correcta.
  2. La reparación puede empeorar el turno. En comparar-segmentos-acero cambió un cierre con tres anclas por un párrafo vacío, y pasó. Falta un medidor que compare intento 1 contra intento 2.
  3. Cola de latencia. p90 de 131.6 s contra un deadline de 150 s. permisos-construccion-queretaro gastó 10 herramientas y 9 steps; cuantos-transportistas-bajio tardó 132 s para devolver una sola cifra. Hay tope de steps, no de herramientas.
  4. Los evals 4 y 6 son parciales; 1, 2 y 8 no están. El bench cubre 3 de los 8 evals bloqueantes por completo. El 1 vive en un spec de CI; 2 y 8 esperan a F2.
  5. El brazo de control nunca se corrió. --no-manual y LUNA_BASELINE_SYSTEM existen, pero los cinco dumps son con manual. No hay un solo número que diga si los 12 857 caracteres del manual valen lo que cuestan.