Phase 6.3.17: fix a harness fidelity bug and add a vibrancy metric

The first baseline measured production with only 2290 of the 16392
characters of the server's instructions block - 14%. The missing 86% is
exactly the API grounding: Core Shape Properties and Methods, Layout
Systems, Text Elements, and The penpot and penpotUtils Objects, which is
where insertChild, resize(), the layouts and penpotUtils are documented.

That was worth catching, because the discrepancy had a visible signature:
the measurement said production creates nothing, while the user's real
Claude Code session produced grey boxes, i.e. shapes greater than zero.
When a harness and reality disagree, the harness is the first suspect. In
phase 5 a low max_tokens manufactured an apparent regression the same way.

The gate now injects the full document, minus the trailing "You have hereby
read the Penpot High-Level Overview" line, which is framing of the tool
response rather than part of the instructions block and would otherwise
tell the model it had already read something.

The finding survives the fix. Across the five prompts measured cleanly
under the corrected condition, shapeCount is still zero on every one. So
the API invention is not an artefact of withholding documentation from the
model - it happens with the documentation present.

Also adds the vibrancy requirement the user raised as first-class scope:
given an ambiguous brief the model must choose and justify a palette rather
than ask or fall back to defaults. Neither distinctFillColors nor
placeholderGreys distinguishes a vibrant palette from a muted but
technically non-grey one, so four metrics are added: chromaticFills,
meanChromaticSaturation, paletteStructured (a dominant brand hue, an accent
at least 30 degrees away, and neutrals), and finalMessageListsHex, because
a palette chosen in silence cannot be adjusted by the user.

The saturation floor of 45 is derived, not asserted: measured over the 325
non-neutral fills of this phase's hand-authored corpus, median HSL
saturation is 75, p25 is 48 and p10 is 35. A floor of 45 sits just under
the first quartile and is cleared by 79% of those fills, so it is a floor
the target behaviour already clears rather than an aspiration. The
lightness band of 15 to 85 excludes near-blacks and near-whites, which can
compute as highly saturated while reading as neutral.

Gate prompt 6 becomes the user's literal failing sentence, and two
ambiguous-brief prompts are added. One of them had to be re-domained after
the disjointness check found it shared a 6-gram with a seed - the check
fails on a single shared shingle, which is what makes it useful.

Results so far are partial: prompts 1-5 measured cleanly, 6 has a timed-out
audit and 7-10 hit the MCP outage, so those get re-measured. Both runs are
kept, the 14% one renamed to record what it was.
This commit is contained in:
2026-07-30 18:38:24 +00:00
parent 5f0ddd962c
commit 8094183940
6 changed files with 7903 additions and 52 deletions
@@ -0,0 +1,77 @@
[OK] los 8 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[MCP] sonda de conectividad del plugin: {
"result": {
"gate5Probe": true
},
"log": ""
}
--- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T175220Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-01-boton-primario: se agotaron los 14 turnos sin mensaje final
[WARN] g5-01-boton-primario: export_shape no devolvio binario
score=0.0 (2/14) grises=0 prohibido=7 shapes=0 colores=0 textos=0
--- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T175644Z
[WARN] g5-02-navbar-flex: se agotaron los 14 turnos sin mensaje final
[WARN] g5-02-navbar-flex: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T175753Z
[WARN] g5-03-card-producto: se agotaron los 14 turnos sin mensaje final
[WARN] g5-03-card-producto: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T175855Z
[WARN] g5-04-tokens-biblioteca: se agotaron los 14 turnos sin mensaje final
[WARN] g5-04-tokens-biblioteca: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T175957Z
[WARN] g5-05-grid-tres-diferenciales: se agotaron los 14 turnos sin mensaje final
[WARN] g5-05-grid-tres-diferenciales: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T180102Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-06-landing-pizzeria: export_shape no devolvio binario
score=0.0 (3/17) grises=0 prohibido=5 shapes=0 colores=0 textos=0
--- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T180525Z
[WARN] g5-07-onboarding-mobile: se agotaron los 14 turnos sin mensaje final
[WARN] g5-07-onboarding-mobile: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T180627Z
[WARN] g5-08-reparacion-grises: se agotaron los 14 turnos sin mensaje final
[WARN] g5-08-reparacion-grises: export_shape no devolvio binario
score=26.7 (4/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
=== Puerta 5 -- calidad de diseno en Penpot (tag=prod-baseline) ===
prompt dif score pasa grises prohib shapes colores textos
g5-01-boton-primario baja 0.0 2/14 0 7 0 0 0
g5-02-navbar-flex baja 17.6 3/17 0 0 0 0 0
g5-03-card-producto media 20.0 3/15 0 0 0 0 0
g5-04-tokens-biblioteca media 20.0 3/15 0 0 0 0 0
g5-05-grid-tres-diferenciales media 17.6 3/17 0 0 0 0 0
g5-06-landing-pizzeria alta 0.0 3/17 0 5 0 0 0
g5-07-onboarding-mobile alta 17.6 3/17 0 0 0 0 0
g5-08-reparacion-grises alta 26.7 4/15 0 0 0 0 0
condiciones de aprobacion:
[NO ] score medio >= 65 -> 14.9
[NO ] veto limpio en 8/8 -> 6/8
[NO ] >= 6/8 prompts >= 60 -> 0/8
[NO ] landing de pizzeria >= 60 -> 0.0
VEREDICTO GLOBAL: NO APRUEBA
tiempo total: 15.1 min
resultados en data/gate5_results_prod-baseline.json
PNGs en data/gate5_png_prod-baseline/ (la puerta no borra nada: las paginas quedan para inspeccion humana)
+26 -48
View File
@@ -1,4 +1,5 @@
[OK] los 8 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia)
[SYSTEM] documento completo del servidor: 16293 chars (247 lineas)
[OK] los 10 prompts son disjuntos de train/eval/seeds (7472 shingles de referencia)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[MCP] sonda de conectividad del plugin: {
"result": {
@@ -7,71 +8,48 @@
"log": ""
}
--- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T175220Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-01-boton-primario: se agotaron los 14 turnos sin mensaje final
--- g5-01-boton-primario (baja) -> pagina gate5/prod-baseline/g5-01-boton-primario/20260730T182503Z
[WARN] g5-01-boton-primario: export_shape no devolvio binario
score=0.0 (2/14) grises=0 prohibido=7 shapes=0 colores=0 textos=0
score=0.0 (2/14) grises=0 prohibido=5 shapes=0 colores=0 textos=0
--- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T175644Z
--- g5-02-navbar-flex (baja) -> pagina gate5/prod-baseline/g5-02-navbar-flex/20260730T182608Z
[WARN] g5-02-navbar-flex: se agotaron los 14 turnos sin mensaje final
[WARN] g5-02-navbar-flex: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
score=0.0 (3/17) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T175753Z
--- g5-03-card-producto (media) -> pagina gate5/prod-baseline/g5-03-card-producto/20260730T182706Z
[WARN] g5-03-card-producto: se agotaron los 14 turnos sin mensaje final
[WARN] g5-03-card-producto: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T175855Z
[WARN] g5-04-tokens-biblioteca: se agotaron los 14 turnos sin mensaje final
--- g5-04-tokens-biblioteca (media) -> pagina gate5/prod-baseline/g5-04-tokens-biblioteca/20260730T182804Z
[WARN] g5-04-tokens-biblioteca: export_shape no devolvio binario
score=20.0 (3/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
score=0.0 (3/15) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T175957Z
[WARN] g5-05-grid-tres-diferenciales: se agotaron los 14 turnos sin mensaje final
--- g5-05-grid-tres-diferenciales (media) -> pagina gate5/prod-baseline/g5-05-grid-tres-diferenciales/20260730T182903Z
[WARN] g5-05-grid-tres-diferenciales: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
score=23.5 (4/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T180102Z
--- g5-06-landing-pizzeria (alta) -> pagina gate5/prod-baseline/g5-06-landing-pizzeria/20260730T183005Z
[MCP] fallo transitorio en 'tools/call' (intento 1/5): error de transporte contra el MCP de Penpot: ConnectionError. Reintento en 4s
[MCP] rehaciendo el handshake (la sesion pudo haber muerto)
[MCP] handshake ok con 'penpot-mcp-server'; tools: execute_code, high_level_overview, penpot_api_info, export_shape
[WARN] g5-06-landing-pizzeria: la auditoria fallo (g5-06-landing-pizzeria: auditoria: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 374cec0b-2e20-40bc-b58e-44e7ae20b6b0 timed out after 30 seconds)
[WARN] g5-06-landing-pizzeria: export_shape no devolvio binario
score=0.0 (3/17) grises=0 prohibido=5 shapes=0 colores=0 textos=0
score=0.0 (2/21) grises=0 prohibido=1 shapes=0 colores=0 textos=0
--- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T180525Z
[WARN] g5-07-onboarding-mobile: se agotaron los 14 turnos sin mensaje final
[WARN] g5-07-onboarding-mobile: export_shape no devolvio binario
score=17.6 (3/17) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-07-onboarding-mobile (alta) -> pagina gate5/prod-baseline/g5-07-onboarding-mobile/20260730T183529Z
[ERROR] g5-07-onboarding-mobile: g5-07-onboarding-mobile: setup de la pagina: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 98dda7fa-baed-4e2e-99f4-fa8bf33c1326 timed out after 30 seconds
[INFO] se guardan los 6 prompt(s) ya medidos y se sigue con el siguiente; el JSON queda marcado como parcial
--- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T180627Z
[WARN] g5-08-reparacion-grises: se agotaron los 14 turnos sin mensaje final
[WARN] g5-08-reparacion-grises: export_shape no devolvio binario
score=26.7 (4/15) grises=0 prohibido=0 shapes=0 colores=0 textos=0
--- g5-08-reparacion-grises (alta) -> pagina gate5/prod-baseline/g5-08-reparacion-grises/20260730T183559Z
[ERROR] g5-08-reparacion-grises: g5-08-reparacion-grises: setup de la pagina: el MCP no devolvio JSON (texto): Tool execution failed: Error: Task 725a0fd0-3c6e-4b4b-bf1c-bb65776c4fb0 timed out after 30 seconds
[INFO] se guardan los 7 prompt(s) ya medidos y se sigue con el siguiente; el JSON queda marcado como parcial
=== Puerta 5 -- calidad de diseno en Penpot (tag=prod-baseline) ===
--- g5-09-brief-ambiguo-escuela (alta) -> pagina gate5/prod-baseline/g5-09-brief-ambiguo-escuela/20260730T183629Z
prompt dif score pasa grises prohib shapes colores textos
g5-01-boton-primario baja 0.0 2/14 0 7 0 0 0
g5-02-navbar-flex baja 17.6 3/17 0 0 0 0 0
g5-03-card-producto media 20.0 3/15 0 0 0 0 0
g5-04-tokens-biblioteca media 20.0 3/15 0 0 0 0 0
g5-05-grid-tres-diferenciales media 17.6 3/17 0 0 0 0 0
g5-06-landing-pizzeria alta 0.0 3/17 0 5 0 0 0
g5-07-onboarding-mobile alta 17.6 3/17 0 0 0 0 0
g5-08-reparacion-grises alta 26.7 4/15 0 0 0 0 0
condiciones de aprobacion:
[NO ] score medio >= 65 -> 14.9
[NO ] veto limpio en 8/8 -> 6/8
[NO ] >= 6/8 prompts >= 60 -> 0/8
[NO ] landing de pizzeria >= 60 -> 0.0
VEREDICTO GLOBAL: NO APRUEBA
tiempo total: 15.1 min
resultados en data/gate5_results_prod-baseline.json
PNGs en data/gate5_png_prod-baseline/ (la puerta no borra nada: las paginas quedan para inspeccion humana)
[ABORTA] el servidor MCP de Penpot no tiene ningun plugin conectado.
Abri el archivo de Penpot en el navegador, arranca el plugin del MCP y espera a
que quede conectado; recien ahi volve a correr la puerta. Sin plugin conectado no
tiene sentido gastar los 8 prompts.
Mensaje del servidor: Tool execution failed: Error: No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.
+3 -1
View File
@@ -3,6 +3,8 @@
{"id": "g5-03-card-producto", "dificultad": "media", "prompt": "Hacé una card de producto de 320 de ancho con una foto real traída de https://picsum.photos/seed/espresso/640/480, un título, una bajada de dos líneas, el precio destacado y un botón de compra. La foto tiene que ser la imagen de verdad: nada de rectángulos grises simulando la imagen."}
{"id": "g5-04-tokens-biblioteca", "dificultad": "media", "prompt": "Creá en la biblioteca local del archivo una paleta de 6 colores nombrados por rol y una escala tipográfica de 5 pasos, y después aplicá esos tokens a una muestra visible: un encabezado, un párrafo de apoyo y tres chips que muestren los colores aplicados desde la biblioteca."}
{"id": "g5-05-grid-tres-diferenciales", "dificultad": "media", "prompt": "Componé una sección de 3 diferenciales de 1140 de ancho resuelta con un grid de 3 columnas: cada columna con una figura geométrica propia a modo de ícono, un titular corto y dos líneas de texto de apoyo, todo con la misma alineación y respiración entre columnas."}
{"id": "g5-06-landing-pizzeria", "dificultad": "alta", "prompt": "hazme una landing page de una pizzería napolitana con colores vibrantes, de 1200 de ancho"}
{"id": "g5-06-landing-pizzeria", "dificultad": "alta", "prompt": "hazme una landing page de una pizzería con colores vibrantes", "brief": "ambiguo"}
{"id": "g5-07-onboarding-mobile", "dificultad": "alta", "prompt": "Diseñá una pantalla mobile de onboarding de 375x812 para una app de reservas: ilustración o figura de bienvenida arriba, titular, párrafo explicativo, indicador de tres pasos y dos acciones al pie (una primaria y una secundaria)."}
{"id": "g5-08-reparacion-grises", "dificultad": "alta", "prompt": "Este board está hecho enteramente de rectángulos grises y no tiene una sola línea de texto. Auditalo, definí una paleta y una escala tipográfica, y rehacelo hasta que sea una pantalla de verdad, con contenido legible y jerarquía visual.", "seed_board": {"nombre": "Wireframe sin disenar", "x": 0, "y": 0, "ancho": 960, "alto": 640, "gris": "#B1B2B5", "rectangulos": [{"nombre": "header", "x": 32, "y": 32, "ancho": 896, "alto": 88}, {"nombre": "hero", "x": 32, "y": 144, "ancho": 896, "alto": 208}, {"nombre": "card-1", "x": 32, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-2", "x": 336, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "card-3", "x": 640, "y": 376, "ancho": 288, "alto": 160}, {"nombre": "footer", "x": 32, "y": 560, "ancho": 896, "alto": 48}]}}
{"id": "g5-09-brief-ambiguo-escuela", "dificultad": "alta", "brief": "ambiguo", "prompt": "necesito la home de una escuela de música para chicos, que sea alegre; no tengo paleta ni logo, elegí vos"}
{"id": "g5-10-brief-ambiguo-notaria", "dificultad": "alta", "brief": "ambiguo", "prompt": "quiero el sitio de una notaría, que transmita seriedad; no te paso colores"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+169 -3
View File
@@ -94,6 +94,8 @@ PROMPT_SCORE_MIN = 60.0
MIN_PROMPTS_OVER_MIN = 6
HOLDOUT_FORBIDDEN_RATE_MAX = 0.05
HEX_EN_TEXTO = re.compile(r"#[0-9a-fA-F]{6}\b")
PLUGIN_NOT_CONNECTED = "No Penpot plugin instances are currently connected"
EXIT_PLUGIN_NOT_CONNECTED = 2
@@ -597,7 +599,54 @@ try {
const tamsUnicos = [];
for (const tam of fontSizes) { if (tamsUnicos.indexOf(tam) === -1) { tamsUnicos.push(tam); } }
// VIBRACION. Ni `distinctFillColors` ni `placeholderGreys` distinguen una paleta vibrante de
// una apagada pero tecnicamente no gris: seis tonos polvorientos pasan las dos. Esto mide
// saturacion de verdad, en HSL.
function aHsl(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
const l = (mx + mn) / 2;
let h = 0, sat = 0;
if (d !== 0) {
sat = d / (1 - Math.abs(2 * l - 1));
if (mx === r) { h = ((g - b) / d) % 6; }
else if (mx === g) { h = (b - r) / d + 2; }
else { h = (r - g) / d + 4; }
h = h * 60; if (h < 0) { h += 360; }
}
return { h: h, s: sat * 100, l: l * 100 };
}
const cromaticos = [];
const neutrales = [];
for (const hex of distintos) {
const c = aHsl(hex);
// Cromatico: saturacion >= 45 y luminosidad en 15..85. La banda de luminosidad excluye los
// casi-negros y casi-blancos, que pueden dar saturacion alta y sin embargo leerse neutros.
if (c.s >= 45 && c.l >= 15 && c.l <= 85) { cromaticos.push({ hex: hex, h: c.h, s: c.s, l: c.l }); }
else if (c.s <= 15) { neutrales.push(hex); }
}
// Estructura de paleta: una marca dominante, un acento con tono claramente distinto, y neutrales.
let separacionDeTono = 0;
for (let i = 0; i < cromaticos.length; i++) {
for (let j = i + 1; j < cromaticos.length; j++) {
let d = Math.abs(cromaticos[i].h - cromaticos[j].h);
if (d > 180) { d = 360 - d; }
if (d > separacionDeTono) { separacionDeTono = d; }
}
}
const saturacionMedia = cromaticos.length
? cromaticos.reduce(function (a, c) { return a + c.s; }, 0) / cromaticos.length : 0;
return {
chromaticFills: cromaticos.length,
chromaticSamples: cromaticos.slice(0, 8).map(function (c) {
return { hex: c.hex, s: Math.round(c.s), l: Math.round(c.l) }; }),
meanChromaticSaturation: Math.round(saturacionMedia),
neutralFills: neutrales.length,
hueSeparation: Math.round(separacionDeTono),
paletteStructured: cromaticos.length >= 2 && neutrales.length >= 1 && separacionDeTono >= 30,
shapeCount: nodes.length,
distinctFillColors: distintos.length,
fillColors: distintos.slice(0, 24),
@@ -727,6 +776,10 @@ def check_disjunto(prompts):
METRICS = [
("shapeCount", "conteo de shapes", "min"),
("distinctFillColors", "fillColor distintos (sin blanco/negro puros)", "min"),
("chromaticFills", "fills cromaticos (sat HSL >= 45, L 15-85)", "min"),
("meanChromaticSaturation", "saturacion media de los fills cromaticos", "min"),
("paletteStructured", "paleta estructurada (marca + acento + neutrales)", "bool"),
("finalMessageListsHex", "el mensaje final enumera los hex elegidos", "bool"),
("placeholderGreys", "grises de placeholder [VETO]", "max"),
("textsWithCharacters", "textos con characters no vacio", "min"),
("typeScale", "fontSize distintos y maximo", "pair"),
@@ -749,6 +802,27 @@ VETO_METRICS = ("placeholderGreys", "forbiddenBehavior")
# Umbrales por prompt. `None` = la metrica NO aplica a ese prompt y no entra en el denominador
# del score. Estan aca y no en el .jsonl a proposito: el .jsonl describe la TAREA, el umbral es
# criterio de la puerta y se versiona junto al codigo que lo aplica.
# ------------------------------------------------------------------------------------------
# Umbrales de VIBRACION, solo para los prompts de brief ambiguo
# ------------------------------------------------------------------------------------------
# Requisito explicito del usuario: ante un brief ambiguo el modelo tiene que ELEGIR una paleta y
# justificarla, no preguntar ni caer en defaults. Ni `distinctFillColors` ni `placeholderGreys`
# distinguen una paleta vibrante de una apagada pero tecnicamente no gris.
#
# El umbral de saturacion 45 NO es una opinion: sale de medir las 325 paletas no neutrales del
# corpus escrito a mano de esta fase. Su saturacion HSL mediana es 75, con p25 en 48 y p10 en 35.
# Un piso de 45 queda apenas por debajo del primer cuartil -- lo pasa el 79% de esos fills -- asi
# que es un suelo que el comportamiento objetivo despeja comodo, no una aspiracion. La banda de
# luminosidad 15..85 excluye casi-negros y casi-blancos, que pueden dar saturacion alta y aun asi
# leerse neutros.
UMBRALES_VIBRACION = {
"chromaticFills": 2,
"meanChromaticSaturation": 45,
"paletteStructured": True,
"finalMessageListsHex": True,
}
UMBRALES_VIBRACION_NO_APLICA = {k: None for k in UMBRALES_VIBRACION}
THRESHOLDS = {
"g5-01-boton-primario": {
"shapeCount": 2, "distinctFillColors": 2, "placeholderGreys": 0,
@@ -757,6 +831,7 @@ THRESHOLDS = {
"structure": None, "styleRichness": 2, "rootHeightMismatch": 24,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-02-navbar-flex": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -765,6 +840,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 1, "rootHeightMismatch": 24,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-03-card-producto": {
"shapeCount": 6, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -773,6 +849,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-04-tokens-biblioteca": {
"shapeCount": 8, "distinctFillColors": 6, "placeholderGreys": 0,
@@ -781,6 +858,7 @@ THRESHOLDS = {
"structure": (2, 1), "styleRichness": 2, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-05-grid-tres-diferenciales": {
"shapeCount": 12, "distinctFillColors": 3, "placeholderGreys": 0,
@@ -789,6 +867,28 @@ THRESHOLDS = {
"structure": (3, 3), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-09-brief-ambiguo-escuela": {
"shapeCount": 30, "distinctFillColors": 6, "placeholderGreys": 0,
"textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0,
"layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0,
"structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION,
},
"g5-10-brief-ambiguo-notaria": {
"shapeCount": 30, "distinctFillColors": 5, "placeholderGreys": 0,
"textsWithCharacters": 12, "typeScale": (3, 32), "textFillShare": 1.0,
"layoutBoards": 3, "layoutChildSizingShare": 0.5, "containmentViolations": 0,
"structure": (3, 4), "styleRichness": 3, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
# "elegante" no pide estridencia: alcanza con UN cromatico bien elegido sobre neutrales,
# pero la paleta tiene que estar estructurada y declarada igual.
"chromaticFills": 1, "meanChromaticSaturation": 30,
"paletteStructured": True, "finalMessageListsHex": True,
},
"g5-06-landing-pizzeria": {
"shapeCount": 25, "distinctFillColors": 5, "placeholderGreys": 0,
@@ -797,6 +897,7 @@ THRESHOLDS = {
"structure": (3, 5), "styleRichness": 5, "rootHeightMismatch": 48,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION,
},
"g5-07-onboarding-mobile": {
"shapeCount": 10, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -805,6 +906,7 @@ THRESHOLDS = {
"structure": (3, 2), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
"g5-08-reparacion-grises": {
"shapeCount": 8, "distinctFillColors": 4, "placeholderGreys": 0,
@@ -813,6 +915,7 @@ THRESHOLDS = {
"structure": (2, 2), "styleRichness": 3, "rootHeightMismatch": 32,
"exportBeforeFinal": True, "forbiddenBehavior": 0, "exceptionRate": 0.34,
"fontIdBoundShare": 1.0, "rendersOk": True,
**UMBRALES_VIBRACION_NO_APLICA,
},
}
@@ -838,6 +941,10 @@ def build_raw_metrics(audit, runtime):
"exportBeforeFinal": runtime["exportBeforeFinal"],
"forbiddenBehavior": runtime["forbiddenBehavior"],
"exceptionRate": runtime["exceptionRate"],
"chromaticFills": audit.get("chromaticFills", 0),
"meanChromaticSaturation": audit.get("meanChromaticSaturation", 0),
"paletteStructured": bool(audit.get("paletteStructured", False)),
"finalMessageListsHex": runtime["finalMessageListsHex"],
"fontIdBoundShare": audit.get("fontIdBoundShare", 0.0),
"rendersOk": bool(audit.get("cssLength", 0) > 0 and audit.get("markupLength", 0) > 0
and not audit.get("renderError")),
@@ -901,9 +1008,64 @@ def load_penpot_tools():
def load_system_prompt():
"""El bloque `instructions` verbatim del servidor MCP. Medir con otro system prompt mediria
otra cosa: es exactamente lo que el modelo ve en produccion."""
return LINT.extract_system_block()
"""El bloque `instructions` COMPLETO del servidor MCP, verbatim.
Medir con otro system prompt mide otra cosa. Y esto se equivoco una vez, caro:
La primera corrida del baseline inyectaba solo la seccion inicial del documento (la que
`penpot_system_prompt.md` guarda como "Bloque completo, verbatim"), que son 2290 de los
16396 caracteres reales -- el 14%. El 86% que faltaba es justo el grounding de API:
"Core Shape Properties and Methods", "Layout Systems", "Text Elements" y "The `penpot` and
`penpotUtils` Objects", o sea insertChild, resize(), los layouts y penpotUtils.
Con ese recorte, produccion media shapes=0 en los 8 prompts: sin ninguna documentacion de
API, el modelo se inventaba una con forma de Figma y todas las llamadas lanzaban. Pero el
sintoma que el usuario reporto en su sesion real de Claude Code era otro y menos severo
("se conecta y crea cajas grises"), o sea shapes>0. Esa discrepancia era la firma de que el
harness, y no el modelo, estaba produciendo el resultado.
En una sesion real el servidor MCP entrega el documento entero como `instructions`, que es
tambien lo que devuelve `high_level_overview`. Asi que la fuente correcta es la Captura 1 de
penpot_api_docs.md, completa.
Nota deliberada sobre los seeds: los 34 seeds que llevan mensaje `system` cargan solo la
seccion inicial, no las 4554 tokens del documento entero -- no entrarian en el presupuesto
de 3300 tokens por ejemplo. Eso es aceptable porque la contramedida que los seeds tienen que
ensenar (la regla de "don't pick your own colours" y el razonamiento que la desambigua) vive
entera en esa seccion inicial.
"""
if os.environ.get("GATE5_SYSTEM_PROMPT_SECTION_ONLY", "").lower() in ("1", "true", "yes"):
print("[SYSTEM] usando SOLO la seccion inicial del documento (modo de diagnostico)")
return LINT.extract_system_block()
lineas = (SCHEMAS_DIR / "penpot_api_docs.md").read_text(encoding="utf-8").splitlines()
try:
inicio = next(n for n, l in enumerate(lineas) if l.strip() == "Salida verbatim:") + 3
fin = next(n for n in range(inicio + 5, len(lineas)) if lineas[n].rstrip() == "```")
except StopIteration:
raise Gate5Error(
"no se pudo extraer la Captura 1 (high_level_overview) de penpot_api_docs.md; "
"ese bloque es el system prompt real del servidor y sin el la medicion no es fiel"
)
completo = "\n".join(lineas[inicio:fin])
# La captura viene de `high_level_overview`, que cierra con un separador y la frase
# "You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it
# again." Eso es framing de la RESPUESTA DE LA TOOL, no parte del bloque `instructions` que
# el servidor inyecta. Dejarlo adentro le estaria diciendo al modelo que ya leyo un documento
# por haberlo recibido, lo que altera su decision de llamar o no a la tool.
corte = completo.rfind("\n--\nYou have hereby read")
if corte != -1:
completo = completo[:corte]
seccion = LINT.extract_system_block()
if not completo.startswith(seccion[:200]):
raise Gate5Error(
"la Captura 1 de penpot_api_docs.md no arranca como el bloque de "
"penpot_system_prompt.md: uno de los dos archivos se desincronizo"
)
print(f"[SYSTEM] documento completo del servidor: {len(completo)} chars "
f"({len(completo.splitlines())} lineas)")
return completo
def chat_completion(cfg, messages, tools):
@@ -1073,10 +1235,14 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
except Gate5Error as e:
print(f"[WARN] {prompt_id}: no se pudo exportar el PNG ({e})")
# Ante un brief ambiguo el modelo tiene que ELEGIR una paleta y DECIRSELA al usuario, para
# que la pueda ajustar. Una paleta elegida en silencio no es colaborable.
hex_en_final = set(HEX_EN_TEXTO.findall(final_content or ""))
runtime = {
"exportBeforeFinal": bool(export_calls),
"forbiddenBehavior": len(veto_hits),
"exceptionRate": (exec_exceptions / exec_calls) if exec_calls else 0.0,
"finalMessageListsHex": len(hex_en_final) >= 3,
}
raw = build_raw_metrics(audit, runtime)
resultado = score_prompt(prompt_id, raw)