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