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 debench-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.visibleTo →
entitlements.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):
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
refestampado 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.
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():
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.accepted → tool.started/tool.finished (n veces) → narration.delta →
decision.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 desactivaLo que Luna ve — el brief:thinkingen superficies de tool-calling (cuesta tarifa de salida y alarga el ttft).onSteprecibe los deltas y los tira al piso a propósito. Lo observable es la secuencia de llamadas, abajo.
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 decide —
criterionFromMessage 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):
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):
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:
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.delta →
turn.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-2 … bench-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
- 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. - La reparación puede empeorar el turno. En
comparar-segmentos-acerocambió uncierrecon tres anclas por un párrafo vacío, y pasó. Falta un medidor que compare intento 1 contra intento 2. - Cola de latencia. p90 de 131.6 s contra un deadline de 150 s.
permisos-construccion-queretarogastó 10 herramientas y 9 steps;cuantos-transportistas-bajiotardó 132 s para devolver una sola cifra. Hay tope de steps, no de herramientas. - 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.
- El brazo de control nunca se corrió.
--no-manualyLUNA_BASELINE_SYSTEMexisten, 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.
