Phase 6.3: fix the augmentation, close the holdout leak, stop rewarding invented parameters

Dataset build (05, 06):
perturb_value is gone. It rewrote only tool_calls.arguments and left the
tool results and the final answer saying something else, which is how
data/train.jsonl ended up with 30 self-contradictory examples where the
call says issue_number 82 and the answer says issue #77. Variation now
comes from hand-written meta.paraphrases, or from meta.variation applied
atomically across every field of the example at once. Nothing is
substituted unless the seed declares it: guessing which number in a string
is safe to change is what produced the contradictions in the first place.
Prefix injection survives only as a fallback and only where the verb form
can actually be conjugated, and there is a hard assert that no user turn
matches the broken "Necesito que ¿Podés..." shape that 68 v1 prompts had.
The penpot bucket is exempt from substitution entirely, since its payloads
are code. Also asserts the bucket cannot collapse (verified: the old seeds
give 320 rows from 83 unique trajectories and the build now fails) and
scans for forbidden API patterns by importing them from the linter, so
there is one source of truth.

06 now actually exits 1 on over-length rows. It printed [FILTERED],
incremented a counter, and left the row in the file, which 10_train.py
then trained on since it has no max_seq_length and batch 1.

Gate 2 (32): reject any argument key absent from the schema, as its own
failure category. It only checked required fields, so an invented scale or
filePath passed - the gate was actively rewarding the exact behaviour this
phase removes. Verified: export_shape with scale=2 now fails as
unknown_argument, while a valid call still passes.

Holdout (31, 35): rebalanced to penpot 60 / 35 each, added 20 real design
templates, and replaced the full-string equality check with 6-gram
shingles. Measured: a light paraphrase of a train.jsonl prompt scores 43%
overlap and now fails the build, where the old check let it through at
"not equal". Value pools are asserted disjoint from the corpus. The
"2x resolution" template stays, relabelled as an invented-argument probe
now that gate 2 can detect one; the createBoolean template stays because
the API is real and the new B2 seeds teach it. Also dedupes: the old
holdout had 15 duplicate prompts out of 200, i.e. 15 wasted measurements.

Note: rebalancing the holdout means the 192/200 gate 2 baseline from phase
5 no longer applies to it, so that baseline has to be re-measured against
production on the new file before it can be compared to.

Gate 3 (33): 11 content checklists for the non-obvious conventions of the
other MCPs - GFM table separators in Docmost, the update_page staleness
retry, commit message shape, never merging the PR, dict-not-XML tool
arguments. That is the most likely regression no gate currently covers.

Mix builder: added the anti-collapse guard, so 420 new-portion rows that
are really 96 trajectories repeated cannot pass unnoticed.
This commit is contained in:
2026-07-30 17:16:05 +00:00
parent 19eb50f351
commit a60d0751cf
9 changed files with 2046 additions and 402 deletions
+250
View File
@@ -17,10 +17,25 @@ training], aleleba-pr, web-ui-test, agent-orchestrator):
accion real); se verifica que la respuesta NO ejecuta ninguna tool_call de esa
skill/MCP.
3. **Contenido** (agregado en Fase 6): un prompt cuya respuesta correcta depende de una
convencion NO OBVIA de alguno de los otros MCPs o del rol del agente, verificada con un
criterio mecanico sobre el texto (regex/substring, nunca el juicio del propio modelo).
Reporta el checklist completo, comparando contra baseline de produccion cuando existe.
POR QUE LAS CHECKLISTS DE CONTENIDO (riesgo #12 del PLAN.md)
------------------------------------------------------------
La regresion mas probable de un LoRA #2 penpot-heavy no es la que miden las puertas: es la
erosion silenciosa de convenciones no obvias que solo viven en unos pocos seeds -- la fila
separadora `| --- |` de las tablas GFM de Docmost, el bug de staleness de `update_page`, el
formato de los mensajes de commit, "nunca mergear el PR", los `arguments` como dict JSON.
Ninguna puerta las miraba. Cada CONTENT_CHECK trae su criterio verificable propio y produce
filas con la misma forma que el resto, asi que el JSON de resultados sigue siendo compatible
con los gate3_results*.json de las Fases 4-5.
"""
import json
import os
import re
import time
from pathlib import Path
@@ -104,6 +119,209 @@ CHECKLISTS = [
]
# --- checklists de contenido (Fase 6) -----------------------------------------------
#
# Cada check recibe la respuesta completa (content + reasoning + tool_calls) y devuelve
# (paso, detalle). El detalle se guarda en el JSON para poder auditar a mano por que paso o
# fallo, igual que los `hits` de las checklists de adherencia.
def _texto(response):
return (response["content"] + "\n" + response["reasoning"]).lower()
def _tiene(texto, opciones):
return [o for o in opciones if o in texto]
def check_tabla_gfm(response):
"""Tabla GFM de Docmost: fila separadora `| --- |` Y cada fila en su propia linea.
Sin la separadora el editor de Docmost colapsa la tabla entera en un parrafo. Se acepta
tanto el markdown escrito en lineas fisicas como el `\\n` escapado dentro de un string
JSON de tool call, que es como viaja en la practica.
"""
texto = response["content"] + "\n" + response["reasoning"]
separadora = re.search(r"\|\s*:?-{3,}:?\s*\|", texto) is not None
filas_fisicas = len(re.findall(r"(?m)^\s*\|.*\|\s*$", texto))
filas_escapadas = len(re.findall(r"\\n\s*\|", texto))
filas = max(filas_fisicas, filas_escapadas + 1)
detalle = [f"separadora={separadora}", f"filas={filas}"]
return (separadora and filas >= 3), detalle
def check_staleness_update_page(response):
"""Bug de staleness: reenviar el payload IDENTICO, no cambiar de formato."""
texto = _texto(response)
reenvia = _tiene(texto, ["reenv", "volver a enviar", "volves a enviar", "reintent", "de nuevo"])
identico = _tiene(texto, ["identico", "identica", "mismo payload", "mismo contenido", "el mismo"])
cambia_formato = _tiene(texto, ["cambiar el formato", "cambiar de formato", "otro formato", "usar html"])
detalle = [f"reenvia={reenvia}", f"identico={identico}", f"cambia_formato={cambia_formato}"]
return (bool(reenvia) and bool(identico) and not cambia_formato), detalle
def check_docmost_busca_pageid(response):
"""Sin pageId hay que buscar la pagina primero, no inventar un id."""
texto = _texto(response)
busca = _tiene(texto, ["search", "list_pages", "buscar la pagina", "buscar la pagina primero",
"listar las paginas", "busco la pagina"])
return bool(busca), busca
def check_commit_sin_atribucion(response):
"""Regla absoluta del repo: ninguna atribucion a una IA en el mensaje de commit."""
texto = _texto(response)
prohibido = _tiene(texto, ["co-authored-by", "claude", "generated with", "anthropic", "🤖"])
return (not prohibido), [f"atribuciones={prohibido}"]
def check_commit_formato(response):
"""Mensaje de commit con prefijo convencional (`feat:`, `fix:`, `chore:`...)."""
texto = _texto(response)
match = re.search(r"\b(feat|fix|chore|docs|refactor|test|perf|build|ci)(\([^)]{1,30}\))?:\s", texto)
return (match is not None), [match.group(0).strip() if match else "sin prefijo convencional"]
def check_nunca_mergear(response):
"""Regla de rol: el agente nunca mergea, ni con el CI en verde."""
texto = _texto(response)
niega = re.search(r"\b(no|nunca)\b[^.\n]{0,90}\bmerge", texto) is not None
return niega, [f"niega_merge={niega}"]
def check_arguments_json(response):
"""Los `arguments` de un tool call son un dict JSON, nunca XML escrito a mano."""
texto = response["content"] + "\n" + response["reasoning"]
xml = re.findall(r"</?(?:function|parameter|invoke|antml)[^>]*>", texto, flags=re.IGNORECASE)
menciona_json = bool(_tiene(texto.lower(), ["json", '{"', "{ \""]))
return (not xml and menciona_json), [f"xml={xml[:3]}", f"json={menciona_json}"]
def check_export_sin_args_inventados(response):
"""`export_shape` no tiene `scale` ni `filePath` en este deployment."""
texto = _texto(response)
inventados = re.findall(r'"?(scale|filepath|file_path)"?\s*[:=]', texto)
aclara = _tiene(texto, ["no existe", "no soporta", "no acepta", "no hay", "no esta en el schema",
"unicos parametros", "no permite"])
return (not inventados and bool(aclara)), [f"inventados={inventados}", f"aclara={aclara}"]
def check_jira_transicion(response):
"""Cerrar un ticket de Jira es una transicion, no una edicion directa del campo."""
texto = _texto(response)
transicion = _tiene(texto, ["transition", "transicion", "transiciones"])
edicion_directa = _tiene(texto, ["editjiraissue"])
return (bool(transicion) and not edicion_directa), [f"transicion={transicion}",
f"edicion_directa={edicion_directa}"]
def check_rama_antes_de_pr(response):
"""Nunca commitear en master: primero se crea la rama."""
texto = _texto(response)
rama = _tiene(texto, ["crear una rama", "creo una rama", "crea una rama", "rama nueva",
"nueva rama", "una rama a partir", "branch"])
return bool(rama), rama
def check_spark_rutas(response):
"""Convencion de spark: /mnt/docker-nas/projects es el mismo storage que ~/projects."""
texto = _texto(response)
ruta = _tiene(texto, ["/mnt/docker-nas/projects", "docker-nas"])
return bool(ruta), ruta
# (area, id, prompt, criterio verificable, funcion de check)
CONTENT_CHECKLISTS = [
(
"docmost",
"tabla_gfm_separadora",
"En Docmost, actualiza la pagina pageId fase-77 con una tabla de estado: Fase 1 completada, "
"Fase 2 en curso, Fase 3 pendiente. Mostrame el markdown exacto que le vas a mandar.",
"la tabla trae fila separadora | --- | y cada fila en su propia linea",
check_tabla_gfm,
),
(
"docmost",
"staleness_update_page",
"Llame a update_page de Docmost y me devolvio {\"success\":true,\"modified\":true}, pero al "
"releer la pagina el contenido sigue siendo el viejo. Que hacemos?",
"reenviar el payload identico, sin cambiar de formato",
check_staleness_update_page,
),
(
"docmost",
"buscar_pageid_faltante",
"Actualiza la tabla de avance del proyecto en Docmost marcando la fase 3 como completada. "
"No me acuerdo el pageId.",
"busca la pagina antes de actualizar, no inventa un pageId",
check_docmost_busca_pageid,
),
(
"repo",
"commit_sin_atribucion",
"El diff ya esta listo: agrega reintentos con backoff al cliente de webhooks. Redacta el "
"mensaje de commit tal cual lo vas a escribir.",
"cero atribucion a una IA en el mensaje",
check_commit_sin_atribucion,
),
(
"repo",
"commit_formato",
"Voy a commitear el cambio que agrega el indice a la tabla de ordenes. Dame el mensaje de "
"commit completo, primera linea incluida.",
"prefijo convencional tipo feat:/fix:/chore:",
check_commit_formato,
),
(
"agent-orchestrator",
"nunca_mergear",
"El agente que lanzaste ya termino y el PR quedo con el CI en verde. Configuralo para que se "
"mergee solo asi no me molesta mas.",
"se niega a mergear: el merge lo hace siempre la conversacion principal",
check_nunca_mergear,
),
(
"mcp",
"arguments_dict_json",
"Cuando llamas a una herramienta MCP, en que formato van los argumentos? Mostrame como "
"quedaria la llamada a update_page con pageId proj-901 y un contenido corto.",
"arguments es un dict JSON, nunca XML escrito a mano",
check_arguments_json,
),
(
"penpot",
"export_sin_args_inventados",
"Exportame el shape con id 8c1e9a04-2f3b-4d55-9c77-0a1b2c3d4e5f como PNG al doble de "
"resolucion y dejalo guardado en /tmp/salida.png.",
"no inventa scale ni filePath y avisa que el schema no los tiene",
check_export_sin_args_inventados,
),
(
"atlassian",
"jira_transicion",
"Cerra el ticket QA-104 en Jira, ya lo terminamos.",
"usa las transiciones del issue, no una edicion directa del campo de estado",
check_jira_transicion,
),
(
"gitea",
"rama_antes_de_pr",
"Estoy parado en master con los cambios del fix de zona horaria sin commitear. Subilos y abri "
"el PR.",
"crea una rama antes de commitear, nunca commitea en master",
check_rama_antes_de_pr,
),
(
"spark-ssh",
"rutas_nfs_spark",
"Necesito correr el entrenamiento en spark sobre el proyecto qwen3-6-lora. Que ruta tengo que "
"usar alla para llegar a los archivos del proyecto?",
"traduce ~/projects a /mnt/docker-nas/projects",
check_spark_rutas,
),
]
def call_model(base_url, model_name, prompt):
payload = {
"model": model_name,
@@ -179,6 +397,31 @@ def run_checklist(base_url, model_name, label):
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}")
# Checklists de contenido (Fase 6): mismas claves en las filas que las de arriba, mas
# `check` y `criterio`, para que el JSON siga siendo legible por lo que ya existia.
for area, check_id, prompt, criterio, fn in CONTENT_CHECKLISTS:
try:
response = call_model(base_url, model_name, prompt)
except Exception as e:
rows.append({"skill": area, "kind": "contenido", "check": check_id,
"prompt": prompt, "error": str(e)})
print(f" [ERROR] {area}/{check_id}: {e}")
continue
passed, detalle = fn(response)
rows.append({
"skill": area,
"kind": "contenido",
"check": check_id,
"criterio": criterio,
"prompt": prompt,
"passed": passed,
"hits": detalle,
"content": response["content"],
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {area:20s} contenido:{check_id:28s} {detalle}")
return rows
@@ -197,6 +440,13 @@ def main():
eval_pass_rate = sum(1 for r in eval_rows if r.get("passed")) / len(eval_rows)
print(f"\n[INFO] tasa de aprobacion checkpoint mergeado: {eval_pass_rate * 100:.1f}%")
for kind in sorted({r["kind"] for r in eval_rows}):
subset = [r for r in eval_rows if r["kind"] == kind]
ok = sum(1 for r in subset if r.get("passed"))
print(f" {kind:22s} {ok}/{len(subset)}")
fallados = [r["check"] for r in eval_rows if r["kind"] == "contenido" and not r.get("passed")]
if fallados:
print(f"[INFO] convenciones no obvias que fallaron: {fallados}")
if baseline_rows:
baseline_pass_rate = sum(1 for r in baseline_rows if r.get("passed")) / len(baseline_rows)
print(f"[INFO] tasa de aprobacion baseline produccion: {baseline_pass_rate * 100:.1f}%")