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
2 changed files with 411 additions and 9 deletions
Showing only changes of commit 19eb50f351 - Show all commits
+382
View File
@@ -0,0 +1,382 @@
"""Fase 6: ensambla la mezcla de entrenamiento del LoRA #2 (diseno en Penpot).
Corre LOCALMENTE (sin GPU, sin modelo, sin tokenizer). Determinista.
python3 scripts/07_build_lora2_mix.py
Produce tres archivos NUEVOS -- nunca toca data/train.jsonl ni data/eval.jsonl:
data/train_lora2.jsonl 900 ejemplos
data/eval_lora2.jsonl ~100 ejemplos, estratificado por porcion
data/calibration_v2.jsonl 256 ejemplos para la calibracion NVFP4
POR QUE data/train.jsonl QUEDA CONGELADO
----------------------------------------
Es la procedencia exacta del modelo que esta hoy en produccion y el baseline de la puerta 1
(0.2750). Ademas su regeneracion NO es idempotente: `stratified_split()` de 05_build_dataset.py
mezcla con un unico RNG sobre la lista concatenada, asi que cambiar el bucket de penpot
reshufflearia el split de TODOS los buckets y el eval.jsonl dejaria de ser comparable. Este
script lo LEE para muestrear replay, nunca lo escribe.
LA MEZCLA
---------
Porcion n %
-------------------------------------------------------
Diseno penpot nuevo (grupos B) 300 33.3
Correcciones explicitas de API 120 13.3
- forma correcta (grupos A, B9, B10) 60
- recuperacion de error (grupo D) 40
- preguntar a la API (grupo C) 20
Replay de capacidad existente 480 53.3
- replay general/coding 180
- otros_mcps 120
- skills_adherencia 70
- penpot basicos YA CORREGIDOS 45
- negativos/delegacion/manejo_errores 65
**53% de replay es deliberadamente conservador.** La banda razonable para replay en SFT
secuencial es 20-50%; la restriccion del usuario es "sin perder nada actual" y la capacidad
nueva es angosta, asi que se paga algo de velocidad de aprendizaje por anclaje.
**Dentro del replay se desvia a proposito de las proporciones de la v1** (37.5% de `replay`
generico en vez de 49%, subiendo `otros_mcps` y `skills_adherencia`). La razon: la habilidad
generica de chat/codigo ya esta anclada por el modelo base y no es lo que un gradiente
penpot-heavy erosiona. Lo fragil es el delta fine-tuneado -- convenciones de tool-call de los
otros MCPs y adherencia a skills -- que es justo lo que miden las puertas 2 y 3.
LOS 45 "PENPOT BASICOS" NO SALEN DE train.jsonl
-----------------------------------------------
El plan los lista dentro del replay, pero no pueden venir de `data/train.jsonl`: 21 de sus 41
seeds de penpot ensenan `findShapeById(page, id)` y 5 usan `shape.layout`. Muestrear ese bucket
seria re-ensenar exactamente el bug que la fase corrige (riesgo #13). El filtro de patrones
prohibidos los eliminaria de todos modos; sacarlos del corpus NUEVO (los seeds simples de los
grupos A y C, que cumplen el mismo rol de "penpot basico" pero con la API correcta) es la unica
lectura coherente. Queda registrado aca porque es una desviacion consciente del texto del plan.
"""
import json
import os
import random
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SEEDS_PENPOT = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
REPLAY_SOURCE = REPO_ROOT / "data" / "train.jsonl"
TRAIN_OUT = Path(os.environ.get("TRAIN_LORA2_OUT", REPO_ROOT / "data" / "train_lora2.jsonl"))
EVAL_OUT = Path(os.environ.get("EVAL_LORA2_OUT", REPO_ROOT / "data" / "eval_lora2.jsonl"))
CALIB_OUT = Path(os.environ.get("CALIB_V2_OUT", REPO_ROOT / "data" / "calibration_v2.jsonl"))
SEED = 6006
N_TRAIN = 900
EVAL_FRACTION = 0.10
N_CALIBRATION = 256
# grupo de seed -> porcion de la mezcla
PORTION_OF_GROUP = {
"A1": "api_forma_correcta", "A2": "api_forma_correcta", "A3": "api_forma_correcta",
"A4": "api_forma_correcta", "A5": "api_forma_correcta",
"B9": "api_forma_correcta", "B10": "api_forma_correcta",
"B1": "diseno", "B2": "diseno", "B3": "diseno", "B4": "diseno",
"B5": "diseno", "B6": "diseno", "B7": "diseno", "B8": "diseno",
"C": "api_preguntar",
"D": "api_recuperacion",
}
PORTION_TARGETS = {
"diseno": 300,
"api_forma_correcta": 60,
"api_recuperacion": 40,
"api_preguntar": 20,
}
# Replay: bucket de origen en data/train.jsonl -> cuantos ejemplos tomar.
REPLAY_TARGETS = {
"replay": 180,
"otros_mcps": 120,
"skills_adherencia": 70,
"negativos_delegacion_errores": 65, # union de los tres buckets chicos
}
REPLAY_UNION_BUCKETS = ("negativos", "delegacion_subagentes", "manejo_errores")
N_PENPOT_BASICOS = 45
# 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
# filas de train.jsonl que ensenen la API vieja).
FORBIDDEN_REPLAY = [
("findShapeById 2 args", re.compile(r"findShapeById\s*\([^)]*,")),
(".layout inexistente", re.compile(r"\.layout\b(?!Child|Cell)")),
("flex.appendChild", re.compile(r"\.flex\s*\.\s*appendChild\s*\(")),
("createText sin argumento", re.compile(r"createText\s*\(\s*(''|\"\")?\s*\)")),
("textAlign", re.compile(r"\.textAlign\s*=")),
("importImage/createImage/filePath", re.compile(r"\b(importImage|import_image|createImage\s*\(|filePath)\b")),
("setFont", re.compile(r"\.setFont\s*\(")),
("fontSize numerico", re.compile(r"\.(fontSize|fontWeight|lineHeight|letterSpacing)\s*=\s*-?\d")),
]
def load_jsonl(path):
out = []
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
line = line.strip()
if line:
out.append((lineno, json.loads(line)))
return out
def has_forbidden(example):
"""True si el ejemplo serializado contiene algun patron de la API vieja."""
blob = json.dumps(example, ensure_ascii=False)
for _, rx in FORBIDDEN_REPLAY:
if rx.search(blob):
return True
return False
def forbidden_hits(example):
blob = json.dumps(example, ensure_ascii=False)
return [name for name, rx in FORBIDDEN_REPLAY if rx.search(blob)]
def paraphrase_variants(seed_ex):
"""Expande un seed en sus variantes de prompt.
La variacion viene de `meta.paraphrases` -- reescrituras A MANO del turno del usuario -- y
nunca de sustitucion automatica de valores. Es la leccion de la v1: `perturb_value()`
reescribia solo `tool_calls.arguments` y dejaba los tool results y la respuesta final
diciendo otra cosa, produciendo 30 ejemplos auto-contradictorios. Un payload de `code` de
Penpot perturbado, ademas, es simplemente codigo roto.
"""
variants = [seed_ex]
paraphrases = seed_ex.get("meta", {}).get("paraphrases") or []
for text in paraphrases:
clone = json.loads(json.dumps(seed_ex, ensure_ascii=False))
for msg in clone["messages"]:
if msg.get("role") == "user":
msg["content"] = text
break
clone.setdefault("meta", {})["variation"] = "paraphrase"
variants.append(clone)
return variants
def fill_to(pool, target, rng, label):
"""Cicla sobre el pool (barajado por pasada) hasta llegar a `target` ejemplos."""
if not pool:
raise SystemExit(f"[ABORT] la porcion '{label}' no tiene ningun ejemplo de origen")
out = []
order = list(range(len(pool)))
n_pass = 0
while len(out) < target:
if n_pass > 0:
rng.shuffle(order)
for i in order:
if len(out) >= target:
break
ex = json.loads(json.dumps(pool[i], ensure_ascii=False))
ex.setdefault("meta", {})["porcion"] = label
if n_pass > 0:
ex["meta"]["repeticion"] = n_pass
out.append(ex)
n_pass += 1
print(f"[INFO] porcion '{label}': {len(pool)} ejemplos de origen -> {len(out)} "
f"({n_pass} pasada(s))")
return out
def sample_replay(rows, target, rng, label):
"""Muestra determinista SIN repeticion, ya filtrada de patrones prohibidos."""
pool = [ex for _, ex in rows]
if len(pool) < target:
print(f"[WARN] replay '{label}': solo hay {len(pool)} filas limpias para un target de "
f"{target}; se toman todas y se completa ciclando")
return fill_to(pool, target, rng, f"replay_{label}")
idx = list(range(len(pool)))
rng.shuffle(idx)
out = []
for i in idx[:target]:
ex = json.loads(json.dumps(pool[i], ensure_ascii=False))
ex.setdefault("meta", {})["porcion"] = f"replay_{label}"
out.append(ex)
print(f"[INFO] replay '{label}': {len(pool)} filas limpias -> {len(out)} muestreadas")
return out
def stratified_split(examples, rng):
by_portion = defaultdict(list)
for ex in examples:
by_portion[ex["meta"]["porcion"]].append(ex)
train, eval_ = [], []
for portion, rows in sorted(by_portion.items()):
shuffled = rows[:]
rng.shuffle(shuffled)
n_eval = max(1, round(len(shuffled) * EVAL_FRACTION))
eval_.extend(shuffled[:n_eval])
train.extend(shuffled[n_eval:])
print(f"[INFO] split '{portion}': {len(shuffled)} -> train={len(shuffled) - n_eval} "
f"eval={n_eval}")
rng.shuffle(train)
rng.shuffle(eval_)
return train, eval_
def write_jsonl(path, rows):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for ex in rows:
f.write(json.dumps(ex, ensure_ascii=False))
f.write("\n")
print(f"[OK] {path} escrito: {len(rows)} ejemplos")
def main():
for path in (SEEDS_PENPOT, REPLAY_SOURCE):
if not path.exists():
raise SystemExit(f"[ABORT] falta {path}")
rng = random.Random(SEED)
# ---- 1. corpus nuevo de penpot ---------------------------------------------------------
seeds = [ex for _, ex in load_jsonl(SEEDS_PENPOT)]
print(f"[INFO] {SEEDS_PENPOT.name}: {len(seeds)} seeds")
dirty_seeds = [(i, forbidden_hits(ex)) for i, ex in enumerate(seeds, start=1)]
# Los seeds de recuperacion de error contienen el patron equivocado a proposito; el lint ya
# verifico que cada uno viene con su error real y su correccion posterior. Aca solo se avisa.
flagged = [(i, hits) for i, hits in dirty_seeds if hits]
if flagged:
print(f"[INFO] {len(flagged)} seed(s) contienen un patron de la API vieja de forma "
f"intencional (material de recuperacion de error): "
f"{[i for i, _ in flagged]}")
by_group = defaultdict(list)
for ex in seeds:
grupo = ex.get("meta", {}).get("grupo")
if grupo not in PORTION_OF_GROUP:
raise SystemExit(
f"[ABORT] seed con meta.grupo={grupo!r}, que no mapea a ninguna porcion. "
f"Grupos conocidos: {sorted(PORTION_OF_GROUP)}"
)
by_group[grupo].append(ex)
print(f"[INFO] seeds por grupo: {dict(sorted((g, len(v)) for g, v in by_group.items()))}")
pools = defaultdict(list)
for grupo, rows in by_group.items():
portion = PORTION_OF_GROUP[grupo]
for ex in rows:
pools[portion].extend(paraphrase_variants(ex))
for portion, rows in sorted(pools.items()):
print(f"[INFO] pool '{portion}': {len(rows)} variantes (seeds + parafraseos a mano)")
mixed = []
for portion, target in PORTION_TARGETS.items():
mixed.extend(fill_to(pools[portion], target, rng, portion))
# ---- 2. penpot basicos ya corregidos (del corpus NUEVO, ver docstring) -----------------
basicos_pool = []
for grupo in ("A1", "A2", "A3", "A4", "A5", "C"):
basicos_pool.extend(by_group.get(grupo, []))
mixed.extend(fill_to(basicos_pool, N_PENPOT_BASICOS, rng, "replay_penpot_basicos"))
# ---- 3. replay de train.jsonl, filtrado ------------------------------------------------
replay_rows = load_jsonl(REPLAY_SOURCE)
by_bucket = defaultdict(list)
for lineno, ex in replay_rows:
by_bucket[ex.get("meta", {}).get("bucket", "?")].append((lineno, ex))
dropped = Counter()
clean_by_bucket = {}
for bucket, rows in by_bucket.items():
keep = []
for lineno, ex in rows:
hits = forbidden_hits(ex)
if hits:
dropped[bucket] += 1
continue
keep.append((lineno, ex))
clean_by_bucket[bucket] = keep
print("[INFO] scan de patrones prohibidos sobre data/train.jsonl (riesgo #13, "
"'el replay re-ensena el bug'):")
for bucket in sorted(by_bucket):
total = len(by_bucket[bucket])
drop = dropped[bucket]
print(f" {bucket:24} {total:5} filas, {drop:5} descartadas "
f"({100.0 * drop / total if total else 0:.1f}%)")
for label, target in REPLAY_TARGETS.items():
if label == "negativos_delegacion_errores":
rows = []
for b in REPLAY_UNION_BUCKETS:
rows.extend(clean_by_bucket.get(b, []))
else:
rows = clean_by_bucket.get(label, [])
mixed.extend(sample_replay(rows, target, rng, label))
# ---- 4. verificaciones duras -----------------------------------------------------------
if len(mixed) != N_TRAIN:
raise SystemExit(
f"[ABORT] la mezcla tiene {len(mixed)} ejemplos, se esperaban {N_TRAIN}. "
f"Revisar PORTION_TARGETS / REPLAY_TARGETS / N_PENPOT_BASICOS."
)
# Cero patrones prohibidos en la mezcla final, EXCEPTO los seeds de recuperacion de error,
# que se identifican mecanicamente: su porcion es 'api_recuperacion'.
offenders = []
for i, ex in enumerate(mixed):
if ex["meta"].get("porcion") == "api_recuperacion":
continue
hits = forbidden_hits(ex)
if hits:
offenders.append((i, ex["meta"].get("porcion"), hits))
if offenders:
print(f"\n[ABORT] {len(offenders)} ejemplo(s) de la mezcla final contienen patrones de la "
f"API vieja fuera de la porcion de recuperacion de error:")
for i, portion, hits in offenders[:20]:
print(f" #{i} (porcion={portion}): {hits}")
sys.exit(1)
print("[OK] cero patrones de la API vieja en la mezcla final "
"(fuera de la porcion de recuperacion de error, donde son el material didactico)")
counts = Counter(ex["meta"]["porcion"] for ex in mixed)
print("\n=== COMPOSICION DE LA MEZCLA ===")
penpot_new = 0
for portion, n in sorted(counts.items()):
print(f" {portion:28} {n:4} ({100.0 * n / len(mixed):4.1f}%)")
if not portion.startswith("replay"):
penpot_new += n
replay_n = len(mixed) - penpot_new
print(f" {'-' * 28}")
print(f" {'penpot nuevo':28} {penpot_new:4} ({100.0 * penpot_new / len(mixed):4.1f}%)")
print(f" {'replay':28} {replay_n:4} ({100.0 * replay_n / len(mixed):4.1f}%)")
# ---- 5. split y escritura ---------------------------------------------------------------
split_rng = random.Random(SEED)
train, eval_ = stratified_split(mixed, split_rng)
write_jsonl(TRAIN_OUT, train)
write_jsonl(EVAL_OUT, eval_)
# ---- 6. calibracion ----------------------------------------------------------------------
# 256 filas de la mezcla; el script de cuantizacion agrega 256 de ultrachat por su lado, para
# llegar a las 512 de la receta que produjo el checkpoint de produccion (ver
# quantize_nvfp4_v6_mixed_2048.log). Se muestrea de train_lora2 y no de train.jsonl a
# proposito: si la calibracion no ve datos de diseno, NVFP4 puede lavar justo la capacidad
# nueva (riesgo #9).
calib_rng = random.Random(SEED + 1)
calib_idx = list(range(len(train)))
calib_rng.shuffle(calib_idx)
calibration = [train[i] for i in calib_idx[:N_CALIBRATION]]
calib_counts = Counter(ex["meta"]["porcion"] for ex in calibration)
print("\n=== CALIBRACION v2 (distribucion por porcion) ===")
for portion, n in sorted(calib_counts.items()):
print(f" {portion:28} {n:4}")
write_jsonl(CALIB_OUT, calibration)
print("\n[OK] mezcla del LoRA #2 construida. data/train.jsonl y data/eval.jsonl "
"NO fueron modificados.")
if __name__ == "__main__":
main()
+29 -9
View File
@@ -155,12 +155,22 @@ FORBIDDEN = [
# `appendChild` solo es correcto sobre un board con flex (o `grid.appendChild(s, r, c)`). Sobre un
# padre sin layout hay que usar insertChild. No se puede resolver estaticamente en general, asi que
# la heuristica es: si el payload usa `X.appendChild(` con un solo argumento, tiene que haber
# evidencia de flex en el mismo payload.
# la heuristica es exigir evidencia de flex.
#
# El alcance de esa evidencia es EL SEED ENTERO, no el payload. Un seed multi-llamada arma el board
# con flex en la primera llamada y guarda los helpers en `storage`; para cuando la segunda llamada
# hace `main.appendChild(...)`, ni `addFlexLayout(` ni `.flex` aparecen en ese payload. Buscar solo
# dentro del payload marcaba como error justo el patron de persistencia en `storage` que la
# descripcion de `execute_code` pide explicitamente.
APPEND_ONE_ARG_RE = re.compile(r"(\w+)\s*\.\s*appendChild\s*\(\s*[^,()]+\s*\)")
FLEX_EVIDENCE_RE = re.compile(r"addFlexLayout\s*\(|\.flex\b")
GRID_APPEND_RE = re.compile(r"\.grid\s*\.\s*appendChild\s*\(")
# Un gris en el `code` es un problema cuando se APLICA, no cuando se BUSCA. Los seeds de reparacion
# del grupo B8 tienen que poder detectar los grises que van a reemplazar, y para eso necesitan
# nombrarlos. Se permite unicamente en ese grupo y solo en contexto de comparacion/pertenencia.
GREY_COMPARISON_RE = re.compile(r"===|==|!==|!=|\.includes\s*\(|\.indexOf\s*\(|\.some\s*\(|\.has\s*\(")
def is_placeholder_grey(hex_str):
"""R2: saturacion <= 10 Y 100 <= max <= 220. Los casi-negros y casi-blancos pasan."""
@@ -420,6 +430,9 @@ def main():
seen = True
return False
seed_code_blob = "\n".join(code for _, code in ordered_calls)
seed_has_flex = bool(FLEX_EVIDENCE_RE.search(seed_code_blob))
code_by_call = {}
for m in msgs:
for tc in m.get("tool_calls") or []:
@@ -460,7 +473,7 @@ def main():
problems.append(f"{tag}: [{pname}] ...{frag}...\n -> {why}")
# appendChild sobre receptor sin evidencia de flex
if (APPEND_ONE_ARG_RE.search(code) and not FLEX_EVIDENCE_RE.search(code)
if (APPEND_ONE_ARG_RE.search(code) and not seed_has_flex
and not GRID_APPEND_RE.search(code)):
problems.append(
f"{tag}: usa `X.appendChild(shape)` sin ninguna evidencia de flex en el "
@@ -478,13 +491,20 @@ def main():
f"-- el servidor lo prohibe explicitamente (llega duplicado)"
)
# R2: grises de placeholder en el codigo (nunca permitidos, ni en B8)
for hx in HEX_RE.findall(code):
if is_placeholder_grey(hx):
# R2: grises de placeholder en el codigo. Un gris que se APLICA es siempre un
# error; uno que se BUSCA es legitimo, y solo en el grupo de reparacion, que
# necesita nombrar los grises que va a reemplazar.
for code_line in code.split("\n"):
for hx in HEX_RE.findall(code_line):
if not is_placeholder_grey(hx):
continue
if grupo == GREY_INPUT_GROUP and GREY_COMPARISON_RE.search(code_line):
continue
problems.append(
f"{tag}: gris de placeholder {hx} en el `code` (invariante R2). "
f"El gris solo puede aparecer como INPUT en los tool results del "
f"grupo {GREY_INPUT_GROUP}."
f"{tag}: gris de placeholder {hx} aplicado en el `code` "
f"(invariante R2). El gris solo puede aparecer como INPUT en los "
f"tool results del grupo {GREY_INPUT_GROUP}, o nombrado en una "
f"comparacion dentro de ese mismo grupo para detectarlo."
)
# cobertura