Phase 6: train a second LoRA for real Penpot UI design capability #5

Merged
aleleba merged 36 commits from agente-fase6-lora2-penpot into master 2026-08-04 21:36:07 -06:00
5 changed files with 1474 additions and 69 deletions
Showing only changes of commit c9792c5c40 - Show all commits
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+113 -28
View File
@@ -6,7 +6,7 @@ Corre LOCALMENTE (sin GPU, sin modelo, sin tokenizer). Determinista.
Produce tres archivos NUEVOS -- nunca toca data/train.jsonl ni data/eval.jsonl: Produce tres archivos NUEVOS -- nunca toca data/train.jsonl ni data/eval.jsonl:
data/train_lora2.jsonl 900 ejemplos data/train_lora2.jsonl 900 ejemplos (mezcla de 1000, partida 90/10)
data/eval_lora2.jsonl ~100 ejemplos, estratificado por porcion data/eval_lora2.jsonl ~100 ejemplos, estratificado por porcion
data/calibration_v2.jsonl 256 ejemplos para la calibracion NVFP4 data/calibration_v2.jsonl 256 ejemplos para la calibracion NVFP4
@@ -69,7 +69,14 @@ EVAL_OUT = Path(os.environ.get("EVAL_LORA2_OUT", REPO_ROOT / "data" / "eval_lora
CALIB_OUT = Path(os.environ.get("CALIB_V2_OUT", REPO_ROOT / "data" / "calibration_v2.jsonl")) CALIB_OUT = Path(os.environ.get("CALIB_V2_OUT", REPO_ROOT / "data" / "calibration_v2.jsonl"))
SEED = 6006 SEED = 6006
N_TRAIN = 900
# La mezcla se arma con 1000 ejemplos y se parte 90/10, de modo que TRAIN queda en exactamente
# 900. Ese 900 no es cosmetico: 900/16 = 56.25 pasos por epoca x 3 epocas = 168 pasos, que el
# plan aparea deliberadamente con los 166 de la Fase 3 para que la longitud de la trayectoria
# del optimizador sea comparable. Armar 900 y despues sacarle el eval dejaria 811 -> 152 pasos,
# y la comparacion se rompe sin que nada avise.
N_MIX = 1000
N_TRAIN_ESPERADO = 900
EVAL_FRACTION = 0.10 EVAL_FRACTION = 0.10
N_CALIBRATION = 256 N_CALIBRATION = 256
@@ -88,22 +95,24 @@ PORTION_OF_GROUP = {
"D": "api_recuperacion", "D": "api_recuperacion",
} }
# Los targets son sobre la mezcla de 1000; las proporciones son las mismas que documenta el
# docstring (33.3 / 6.7 / 4.4 / 2.2 y 53.3 de replay).
PORTION_TARGETS = { PORTION_TARGETS = {
"diseno": 300, "diseno": 333,
"api_forma_correcta": 60, "api_forma_correcta": 67,
"api_recuperacion": 40, "api_recuperacion": 44,
"api_preguntar": 20, "api_preguntar": 23,
} }
# Replay: bucket de origen en data/train.jsonl -> cuantos ejemplos tomar. # Replay: bucket de origen en data/train.jsonl -> cuantos ejemplos tomar.
REPLAY_TARGETS = { REPLAY_TARGETS = {
"replay": 180, "replay": 200,
"otros_mcps": 120, "otros_mcps": 133,
"skills_adherencia": 70, "skills_adherencia": 78,
"negativos_delegacion_errores": 65, # union de los tres buckets chicos "negativos_delegacion_errores": 72, # union de los tres buckets chicos
} }
REPLAY_UNION_BUCKETS = ("negativos", "delegacion_subagentes", "manejo_errores") REPLAY_UNION_BUCKETS = ("negativos", "delegacion_subagentes", "manejo_errores")
N_PENPOT_BASICOS = 45 N_PENPOT_BASICOS = 50
# Patrones prohibidos. La fuente de verdad es scripts/07_lint_penpot_code.py; aca se re-declaran # Patrones prohibidos. La fuente de verdad es scripts/07_lint_penpot_code.py; aca se re-declaran
# los que aplican al SCAN de replay (no hace falta el set completo: lo que se busca es descartar # los que aplican al SCAN de replay (no hace falta el set completo: lo que se busca es descartar
@@ -130,20 +139,89 @@ def load_jsonl(path):
return out return out
def has_forbidden(example): def code_payloads(example):
"""True si el ejemplo serializado contiene algun patron de la API vieja.""" """Los payloads de `code` del ejemplo, que es lo unico donde un patron prohibido es un bug."""
blob = json.dumps(example, ensure_ascii=False) out = []
for _, rx in FORBIDDEN_REPLAY: for msg in example.get("messages", []):
if rx.search(blob): for tc in msg.get("tool_calls") or []:
return True args = tc.get("function", {}).get("arguments")
return False if isinstance(args, dict) and "code" in args:
out.append(args["code"])
return out
def forbidden_hits(example): def forbidden_hits(example):
blob = json.dumps(example, ensure_ascii=False) """Patrones de la API vieja presentes en el CODIGO del ejemplo.
Se escanea solo el `code`, nunca el ejemplo serializado entero. Escanear la prosa marca
como infractores justo a los seeds correctivos: el que arranca con el usuario pidiendo
"Importa esta imagen con import_image", el que cita verbatim la linea del overview que
menciona `import_image`, el que explica que `board.layout` no existe, el que aclara que
`filePath` esta eliminado del schema. Todos ellos NOMBRAN la API equivocada precisamente
para ensenar a no usarla; bloquearlos seria bloquear la correccion.
"""
blob = "\n".join(code_payloads(example))
return [name for name, rx in FORBIDDEN_REPLAY if rx.search(blob)] return [name for name, rx in FORBIDDEN_REPLAY if rx.search(blob)]
def has_forbidden(example):
return bool(forbidden_hits(example))
def error_strings():
"""Los strings de error verbatim de data/schemas/penpot_errors.md."""
text = (REPO_ROOT / "data" / "schemas" / "penpot_errors.md").read_text(encoding="utf-8")
out = set()
for line in text.splitlines():
if line.startswith("|"):
for cell in line.strip().strip("|").split("|"):
out.update(re.findall(r"`([^`]+)`", cell))
return out
ERROR_STRINGS = error_strings()
def unexplained_forbidden(example):
"""Patrones de la API vieja que NO son un error intencional corregido.
Misma regla mecanica que scripts/07_lint_penpot_code.py: un payload puede traer un patron
prohibido si y solo si (a) su tool result es un string de error real de la allow-list, y
(b) un payload POSTERIOR del mismo ejemplo hace lo mismo SIN el patron. O sea: el error
tiene que haber ocurrido de verdad y tiene que haber sido corregido.
Se deriva del contenido, no de la porcion ni de una bandera en `meta`. Apoyarse en la
porcion se rompe en cuanto un seed de recuperacion de error vive en un grupo que no es el
de recuperacion -- que es exactamente el caso del seed de aridad de `findShapeById` del
grupo A1, cuyo lugar natural es la familia de busqueda.
"""
msgs = example.get("messages", [])
fallidas = {
m.get("tool_call_id")
for m in msgs
if m.get("role") == "tool" and (m.get("content") or "").strip() in ERROR_STRINGS
}
llamadas = []
for m in msgs:
for tc in m.get("tool_calls") or []:
args = tc.get("function", {}).get("arguments")
if isinstance(args, dict) and "code" in args:
llamadas.append((tc.get("id"), args["code"]))
sin_explicar = []
for nombre, rx in FORBIDDEN_REPLAY:
for idx, (cid, code) in enumerate(llamadas):
if not rx.search(code):
continue
corregido = cid in fallidas and any(
not rx.search(c) for _, c in llamadas[idx + 1:]
)
if not corregido:
sin_explicar.append(nombre)
break
return sin_explicar
def paraphrase_variants(seed_ex): def paraphrase_variants(seed_ex):
"""Expande un seed en sus variantes de prompt. """Expande un seed en sus variantes de prompt.
@@ -320,9 +398,9 @@ def main():
mixed.extend(sample_replay(rows, target, rng, label)) mixed.extend(sample_replay(rows, target, rng, label))
# ---- 4. verificaciones duras ----------------------------------------------------------- # ---- 4. verificaciones duras -----------------------------------------------------------
if len(mixed) != N_TRAIN: if len(mixed) != N_MIX:
raise SystemExit( raise SystemExit(
f"[ABORT] la mezcla tiene {len(mixed)} ejemplos, se esperaban {N_TRAIN}. " f"[ABORT] la mezcla tiene {len(mixed)} ejemplos, se esperaban {N_MIX}. "
f"Revisar PORTION_TARGETS / REPLAY_TARGETS / N_PENPOT_BASICOS." f"Revisar PORTION_TARGETS / REPLAY_TARGETS / N_PENPOT_BASICOS."
) )
@@ -330,19 +408,19 @@ def main():
# que se identifican mecanicamente: su porcion es 'api_recuperacion'. # que se identifican mecanicamente: su porcion es 'api_recuperacion'.
offenders = [] offenders = []
for i, ex in enumerate(mixed): for i, ex in enumerate(mixed):
if ex["meta"].get("porcion") == "api_recuperacion": hits = unexplained_forbidden(ex)
continue
hits = forbidden_hits(ex)
if hits: if hits:
offenders.append((i, ex["meta"].get("porcion"), hits)) offenders.append((i, ex["meta"].get("porcion"), hits))
if offenders: if offenders:
print(f"\n[ABORT] {len(offenders)} ejemplo(s) de la mezcla final contienen patrones de la " print(f"\n[ABORT] {len(offenders)} ejemplo(s) de la mezcla final usan la API vieja en su "
f"API vieja fuera de la porcion de recuperacion de error:") f"`code` sin que sea un error real corregido despues:")
for i, portion, hits in offenders[:20]: for i, portion, hits in offenders[:20]:
print(f" #{i} (porcion={portion}): {hits}") print(f" #{i} (porcion={portion}): {hits}")
sys.exit(1) sys.exit(1)
print("[OK] cero patrones de la API vieja en la mezcla final " n_intencionales = sum(1 for ex in mixed if forbidden_hits(ex))
"(fuera de la porcion de recuperacion de error, donde son el material didactico)") print(f"[OK] cero usos injustificados de la API vieja en la mezcla final "
f"({n_intencionales} ejemplos la contienen como error real corregido despues, que es "
f"el material didactico de recuperacion)")
# Guarda anti-colapso, analoga a la de 05_build_dataset.py. Sin ella, la porcion nueva puede # Guarda anti-colapso, analoga a la de 05_build_dataset.py. Sin ella, la porcion nueva puede
# ser 420 ejemplos que son 96 trayectorias repetidas 4.4 veces cada una, y nada lo diria: el # ser 420 ejemplos que son 96 trayectorias repetidas 4.4 veces cada una, y nada lo diria: el
@@ -384,6 +462,13 @@ def main():
# ---- 5. split y escritura --------------------------------------------------------------- # ---- 5. split y escritura ---------------------------------------------------------------
split_rng = random.Random(SEED) split_rng = random.Random(SEED)
train, eval_ = stratified_split(mixed, split_rng) train, eval_ = stratified_split(mixed, split_rng)
if len(train) != N_TRAIN_ESPERADO:
print(f"[WARN] train quedo en {len(train)} y no en {N_TRAIN_ESPERADO}: los pasos de "
f"entrenamiento van a ser {round(len(train) / 16 * 3)} en vez de 168. Es por el "
f"redondeo del split por porcion; ajustar PORTION_TARGETS/REPLAY_TARGETS si "
f"importa la comparabilidad exacta con la Fase 3.")
print(f"[INFO] pasos de entrenamiento proyectados: {len(train)}/16 x 3 epocas = "
f"{round(len(train) / 16 * 3)}")
write_jsonl(TRAIN_OUT, train) write_jsonl(TRAIN_OUT, train)
write_jsonl(EVAL_OUT, eval_) write_jsonl(EVAL_OUT, eval_)