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
4 changed files with 914 additions and 23 deletions
Showing only changes of commit 63da20c031 - Show all commits
+10
View File
@@ -16,3 +16,13 @@ __pycache__/
.ipynb_checkpoints/ .ipynb_checkpoints/
.env .env
.worktrees/ .worktrees/
# Fase 6: checkpoints intermedios del LoRA #2. El adapter final
# (out/lora-adapter-penpot/adapter_model.safetensors, ~169 MB) SI se commitea, igual que el de
# la Fase 3; los checkpoint-*/ del Trainer son decenas de GB y viven solo en spark.
out/lora-adapter-penpot/checkpoint-*/
out/*/checkpoint-*/
# Partes intermedias del corpus de seeds: se concatenan a data/raw/seeds/penpot.jsonl, que es
# el artefacto versionado. Mantener las partes sueltas invita a editar la copia equivocada.
data/raw/seeds/_parts/
+72
View File
@@ -0,0 +1,72 @@
# Dependencias del contenedor de entrenamiento `qwen-lora-train` (docker-compose.yml).
#
# Por que existe este archivo (Fase 6, riesgo #3 del plan): el contenedor de las Fases 3-5
# fue borrado y NADA en el repo fijaba estas versiones. Todo el estado de pip se perdio.
# Una version distinta de transformers puede cambiar la conversion de claves del checkpoint
# (-> los nombres del adapter dejan de matchear lo que espera 20_merge_lora.py) o el
# comportamiento de return_assistant_tokens_mask (-> el masking de 10_train.py entrena
# sobre tokens de system/user/tool sin avisar). Las dos fallas son silenciosas.
#
# Instalar con:
# docker exec qwen-lora-train pip install -c /workspace/ai-projects/qwen3-6-lora/constraints.txt \
# -r /workspace/ai-projects/qwen3-6-lora/requirements.train.txt
#
# El `-c constraints.txt` es obligatorio: fija torch al build de NGC que trae la imagen
# (nvcr.io/nvidia/pytorch:25.12-py3, aarch64/GB10). Sin el, cualquiera de estos paquetes
# puede arrastrar un torch de PyPI que no tiene el runtime CUDA de la imagen.
#
# Verificacion de que la instalacion quedo bien, antes de gastar una corrida:
# 1. 01_inspect_modules.py sobre el checkpoint merged imprime `model.layers.*`
# (no `model.language_model.layers.*`)
# 2. el `trainable%` del smoke run de 2 pasos iguala al de la Fase 3
# Ambos estan en el runbook de la fase (pasos 6.2.7 y 6.4.20).
transformers==5.14.1
peft==0.19.1
# Cuantizacion NVFP4 (21_quantize_nvfp4.py). llmcompressor 0.12.0 es la version con la que
# se produjo el checkpoint que esta hoy en produccion.
llmcompressor==0.12.0
accelerate
datasets
compressed-tensors
safetensors
# bitsandbytes: lo necesita optim="adamw_8bit" en 10_train.py. Sin el, TrainingArguments
# falla al construir el optimizador, ya adentro de la corrida.
bitsandbytes
# NOTA: las cinco ultimas quedan sin pinear a proposito. Son aarch64/GB10 y no todas
# publican wheel para toda version; pinear a ciegas rompe la instalacion en vez de fijarla.
# Las versiones REALMENTE resueltas quedan registradas abajo.
#
# ORDEN DE INSTALACION -- NO es un solo `pip install -r`.
# ---------------------------------------------------------------------------------
# llmcompressor 0.12.0 declara `torch<=2.12.0,>=2.10.0`, y el torch de la imagen NGC es el
# pre-release `2.10.0a0+b4e4ee81d3.nv25.12`. Para el resolvedor de pip un `a0` es ANTERIOR a
# 2.10.0, asi que la restriccion no se satisface y el install entero falla con
# ResolutionImpossible -- aunque el torch instalado sea perfectamente funcional. Por eso
# llmcompressor y compressed-tensors se instalan con --no-deps y sus dependencias reales
# (que no son torch) se instalan aparte:
#
# C=/workspace/ai-projects/qwen3-6-lora/constraints.txt
# pip install --no-cache-dir -c $C transformers==5.14.1 peft==0.19.1 accelerate datasets \
# bitsandbytes safetensors
# pip install --no-cache-dir --no-deps llmcompressor==0.12.0 compressed-tensors
# pip install --no-cache-dir -c $C loguru pydantic tqdm numpy pillow requests
#
# Verificacion (los dos invariantes del riesgo #3 del plan de fase), ya corrida en 6.2.7:
# - 01_inspect_modules.py sobre el merged imprime `model.layers.0.linear_attn.*` [OK]
# - config.json del merged sha256-identico al del base (93a4693fa9d8392f...) [OK]
# - keysets del index identicos: 1045 tensores, 690 bajo model.language_model.* [OK]
# - el `trainable%` del smoke run iguala al de la Fase 3 [paso 6.4.20]
#
# VERSIONES RESUELTAS (pip freeze del contenedor, Fase 6, 2026-07-30):
# torch==2.10.0a0+b4e4ee81d3.nv25.12 (de la imagen, via constraints.txt)
# transformers==5.14.1 tokenizers==0.22.1
# peft==0.19.1 accelerate==1.14.0
# datasets==4.4.1 bitsandbytes==0.50.0
# llmcompressor==0.12.0 compressed-tensors==0.17.1
# safetensors==0.8.0 numpy==2.1.0
# loguru==0.7.3 pydantic==2.12.5
+652
View File
@@ -0,0 +1,652 @@
"""Fase 6: lint estatico del bucket de Penpot. Hard-fail sobre patrones de API prohibidos,
grises de placeholder, tool results fabricados y desbalance de cobertura.
Corre LOCALMENTE. No necesita GPU, ni el modelo, ni el tokenizer -- es a proposito: tiene que
poder correrse cientos de veces mientras se escriben los seeds, sin depender de spark.
python3 scripts/07_lint_penpot_code.py [archivo.jsonl ...]
Sin argumentos lintea data/raw/seeds/penpot.jsonl. Con argumentos lintea los archivos dados
(util para lintear las partes de data/raw/seeds/_parts/ antes de concatenarlas).
POR QUE EXISTE
--------------
Los 41 seeds viejos ensenaban tres formas de API que no existen (findShapeById de 2 argumentos,
shape.layout, createText() sin argumento) y contenian resultados de penpot_api_info fabricados a
mano que afirmaban hechos falsos como si los hubiera dicho el servidor. Nada lo detectaba: el
dataset se veia perfectamente valido. Este script convierte cada una de esas fallas en un error
mecanico, para que un corpus a medio autorar falle ruidosamente en vez de reproducir el problema.
`node --check` es opcional: si node no esta instalado se avisa y se saltea esa verificacion (el
resto del lint corre igual). Con node presente, cada payload se envuelve en una funcion async y
se verifica que parsee.
"""
import json
import re
import shutil
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMAS = REPO_ROOT / "data" / "schemas"
API_DOCS = SCHEMAS / "penpot_api_docs.md"
API_ERRORS = SCHEMAS / "penpot_errors.md"
SYSTEM_PROMPT = SCHEMAS / "penpot_system_prompt.md"
PENPOT_TOOLS = SCHEMAS / "penpot.json"
DEFAULT_TARGET = REPO_ROOT / "data" / "raw" / "seeds" / "penpot.jsonl"
# Los tool results de execute_code de este grupo muestran, a proposito, un estado inicial lleno
# de grises de placeholder: es el INPUT que el asistente audita y repara. Es el unico lugar donde
# un gris puede aparecer, y nunca en el `code` que el asistente escribe.
GREY_INPUT_GROUP = "B8"
# Umbrales de cobertura. Existen para que un corpus a medio autorar falle: sin esto, es
# perfectamente posible escribir 96 seeds que reproduzcan el desbalance actual (1 addFlexLayout,
# 0 addGridLayout, 0 layoutChild) y que el lint pase igual.
COVERAGE_MIN = {
"addGridLayout": 5,
"shadows": 8,
"uploadMediaUrl": 6,
"layoutChild.horizontalSizing": 6,
"addFlexLayout": 12,
"borderRadius": 8,
"fillColorGradient": 4,
"library.local": 5,
"export_shape": 8,
}
MIN_UNIQUE_PAYLOADS = 130
MAX_HIGH_LEVEL_OVERVIEW_CALLS = 2
SYSTEM_SHARE_RANGE = (0.20, 0.45) # ~30% objetivo, con margen
UUID4_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
HEX_RE = re.compile(r"#[0-9a-fA-F]{6}\b")
TOY_ID_RE = re.compile(r"^(r|fb|s|shape|board|txt|t)[-_]?\d{1,3}$", re.I)
# --------------------------------------------------------------------------------------------
# Patrones prohibidos en los payloads de `code`
# --------------------------------------------------------------------------------------------
# Cada entrada: (nombre, regex, explicacion). La explicacion se imprime con el hallazgo, porque
# un lint que solo dice "linea 42: PATRON_7" obliga a ir a leer el lint para entender el error.
FORBIDDEN = [
(
"findShapeById con 2 argumentos",
re.compile(r"findShapeById\s*\([^)]*,"),
"findShapeById tiene aridad 1. La forma de 2 argumentos devuelve null SIN lanzar, y "
"revienta en la linea siguiente. Usar findShapeById(id).",
),
(
"propiedad .layout inexistente",
re.compile(r"\.layout\b(?!Child|Cell)"),
"shape.layout no existe ('layout' in shape === false). Son board.flex y board.grid. "
"La clave `layout` que ves en la salida de shapeStructure() es del OUTPUT del helper, "
"no una propiedad del shape.",
),
(
"flex.appendChild",
re.compile(r"\.flex\s*\.\s*appendChild\s*\("),
"board.flex.appendChild esta roto. Usar board.appendChild(shape), en orden visual.",
),
(
"fontSize/fontWeight/lineHeight/letterSpacing numerico",
re.compile(r"\.(fontSize|fontWeight|lineHeight|letterSpacing)\s*=\s*-?\d"),
"Son strings: fontSize = '48', no fontSize = 48.",
),
(
"textAlign",
re.compile(r"\.textAlign\s*="),
"Asignar textAlign LANZA (`Cannot add property textAlign, object is not extensible`) y "
"mata todo el execute_code. La propiedad es `align`.",
),
(
"propiedad color en Text",
re.compile(r"\b(text|txt|t|label|title|heading)\d*\s*\.\s*color\s*="),
"Text no tiene propiedad `color` y el objeto no es extensible: lanza. El color va en fills.",
),
(
"import_image / importImage / createImage / filePath",
re.compile(r"\b(importImage|import_image|createImage\s*\(|filePath)\b"),
"No existen en este deployment. Imagenes: await penpot.uploadMediaUrl(name, url).",
),
(
"asignacion a propiedad read-only",
re.compile(r"\.(width|height|parentX|parentY|boardX|boardY|bounds)\s*=(?!=)"),
"width/height/parentX/parentY/boardX/boardY/bounds son READ-ONLY. Usar resize(w,h) y "
"penpotUtils.setParentXY(shape, x, y).",
),
(
"shorthand gap",
re.compile(r"\.gap\s*="),
"No existe el shorthand `gap`. Son rowGap y columnGap.",
),
(
"Shadow.color como Fill",
re.compile(r"shadows\s*=\s*\[[^\]]*?color\s*:\s*\{[^}]*fillColor"),
"Shadow.color es un Color ({color, opacity}), no un Fill ({fillColor, fillOpacity}).",
),
(
"typography.setFont",
re.compile(r"\.setFont\s*\("),
"setFont figura en el tipo pero NO existe en el runtime (`t.setFont is not a function`). "
"Setear las propiedades de tipografia una por una.",
),
(
"createText sin argumento util",
re.compile(r"createText\s*\(\s*(''|\"\")?\s*\)"),
"createText() sin argumento y createText('') devuelven null. Es la forma que usa el "
"ejemplo de la documentacion oficial, y es la causa directa de la falla en produccion.",
),
(
"generateStyle con withChildren",
re.compile(r"withChildren\s*:"),
"La opcion real es `includeChildren`. `withChildren` (que usa el overview) se ignora en "
"silencio y el CSS sale sin los hijos.",
),
(
"layoutChild.horizontalSizing = 'fixed' o 'fit-content'",
re.compile(r"layoutChild\s*\.\s*(horizontal|vertical)Sizing\s*=\s*['\"](fixed|fit-content)['\"]"),
"Los valores de layoutChild son 'fill' | 'auto' | 'fix'. 'fit-content' es vocabulario de "
"FlexLayout (aplica al board contenedor), y 'fixed' no existe en ninguno de los dos.",
),
]
# `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.
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*\(")
def is_placeholder_grey(hex_str):
"""R2: saturacion <= 10 Y 100 <= max <= 220. Los casi-negros y casi-blancos pasan."""
r = int(hex_str[1:3], 16)
g = int(hex_str[3:5], 16)
b = int(hex_str[5:7], 16)
mx, mn = max(r, g, b), min(r, g, b)
sat = 0 if mx == 0 else 100.0 * (mx - mn) / mx
return sat <= 10 and 100 <= mx <= 220
def load_jsonl(path):
rows = []
with open(path, encoding="utf-8") as f:
for lineno, raw in enumerate(f, start=1):
raw = raw.strip()
if not raw:
continue
try:
rows.append((lineno, json.loads(raw), len(raw)))
except json.JSONDecodeError as e:
raise SystemExit(f"[FATAL] {path.name}:{lineno}: JSON invalido: {e}")
return rows
def load_doc_lines(path):
"""Todas las lineas no vacias del archivo de captura, para el chequeo de subconjunto."""
return [ln.rstrip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
def extract_system_block():
"""El bloque verbatim de penpot_system_prompt.md, seccion 'Bloque completo, verbatim'."""
text = SYSTEM_PROMPT.read_text(encoding="utf-8")
marker = "## Bloque completo, verbatim"
idx = text.index(marker)
fence_start = text.index("```", idx) + 3
fence_start = text.index("\n", fence_start) + 1
fence_end = text.index("```", fence_start)
return text[fence_start:fence_end].rstrip("\n")
def extract_error_strings():
"""Los strings de error verbatim de penpot_errors.md: todo `codigo` dentro de una celda de
tabla de la columna 'Mensaje verbatim', mas los de las tablas de fallos silenciosos."""
text = API_ERRORS.read_text(encoding="utf-8")
out = set()
for line in text.splitlines():
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
for cell in cells:
for m in re.finditer(r"`([^`]+)`", cell):
out.add(m.group(1))
return out
def extract_return_keys(code):
"""Claves de primer nivel del ultimo `return { ... }` del payload.
Devuelve None si el payload no termina en un return de objeto literal (p.ej. retorna un
array, una expresion, o nada) -- en ese caso el chequeo de claves no aplica.
"""
idx = code.rfind("return {")
if idx == -1:
idx = code.rfind("return{")
if idx == -1:
return None
start = code.index("{", idx)
depth = 0
keys = []
i = start
in_str = None
buf_depth_zero = []
while i < len(code):
ch = code[i]
if in_str:
if ch == "\\":
i += 2
continue
if ch == in_str:
in_str = None
elif ch in "\"'`":
in_str = ch
elif ch in "{[(":
depth += 1
elif ch in "}])":
depth -= 1
if depth == 0 and ch == "}":
break
elif depth == 1:
buf_depth_zero.append((i, ch))
i += 1
if depth != 0:
return None
# Reconstruir el nivel 1 y sacar las claves antes de cada ':'
level1 = "".join(ch for _, ch in buf_depth_zero)
for part in level1.split(","):
if ":" not in part:
# shorthand `{ foo, bar }`
name = part.strip()
if re.fullmatch(r"[A-Za-z_$][\w$]*", name):
keys.append(name)
continue
key = part.split(":", 1)[0].strip().strip("'\"")
if re.fullmatch(r"[A-Za-z_$][\w$]*", key):
keys.append(key)
return keys or None
def node_check(payloads):
"""Verifica que cada payload parsee, envuelto en una funcion async (el servidor evalua el
codigo como cuerpo de funcion, por eso `await` y `return` de nivel superior son validos)."""
node = shutil.which("node")
if not node:
print("[WARN] node no esta instalado -- se saltea la verificacion de sintaxis JS")
return []
problems = []
with tempfile.TemporaryDirectory() as td:
for tag, code in payloads:
f = Path(td) / "chk.mjs"
f.write_text("async function __penpot_payload__(){\n" + code + "\n}\n", encoding="utf-8")
r = subprocess.run([node, "--check", str(f)], capture_output=True, text=True)
if r.returncode != 0:
first = (r.stderr.strip().splitlines() or ["error desconocido"])
msg = next((ln for ln in first if "SyntaxError" in ln), first[-1])
problems.append(f"{tag}: node --check fallo: {msg.strip()}")
return problems
def main():
targets = [Path(a) for a in sys.argv[1:]] or [DEFAULT_TARGET]
for p in targets:
if not p.exists():
raise SystemExit(f"[FATAL] no existe: {p}")
doc_lines = set(load_doc_lines(API_DOCS))
doc_order = load_doc_lines(API_DOCS)
doc_index = {}
for i, ln in enumerate(doc_order):
doc_index.setdefault(ln, []).append(i)
error_strings = extract_error_strings()
system_block = extract_system_block()
canonical_tools = {t["name"]: t for t in json.loads(PENPOT_TOOLS.read_text(encoding="utf-8"))}
problems = []
warnings = []
payloads = [] # (tag, code) para node --check
payload_texts = [] # para unicidad
coverage = Counter()
n_seeds = 0
n_with_system = 0
n_overview_calls = 0
n_intentional_mistakes = 0
max_line_len = 0
grupo_counts = Counter()
for path in targets:
for lineno, ex, raw_len in load_jsonl(path):
n_seeds += 1
tag = f"{path.name}:{lineno}"
max_line_len = max(max_line_len, raw_len)
grupo = ex.get("meta", {}).get("grupo", "?")
grupo_counts[grupo] += 1
if ex.get("meta", {}).get("bucket") != "penpot":
problems.append(f"{tag}: meta.bucket debe ser 'penpot'")
msgs = ex.get("messages", [])
if not msgs:
problems.append(f"{tag}: sin messages")
continue
# ---- mensaje system verbatim -------------------------------------------------
if msgs[0].get("role") == "system":
n_with_system += 1
if msgs[0].get("content", "").strip() != system_block.strip():
problems.append(
f"{tag}: el mensaje system no es el bloque verbatim de "
f"penpot_system_prompt.md (parafraseado o recortado)"
)
# ---- tools copiadas byte a byte ----------------------------------------------
for tool in ex.get("tools") or []:
name = tool.get("name")
if name not in canonical_tools:
problems.append(f"{tag}: tool '{name}' no existe en data/schemas/penpot.json")
continue
canon = canonical_tools[name]
if tool.get("description") != canon.get("description"):
problems.append(
f"{tag}: la description de la tool '{name}' fue recortada o reformulada; "
f"tiene que ser byte a byte la de penpot.json"
)
if tool.get("parameters") != canon.get("parameters"):
problems.append(f"{tag}: el JSON Schema de '{name}' no coincide con penpot.json")
# ---- biyeccion tool_call_id <-> mensajes tool --------------------------------
call_ids, result_ids = [], []
for m in msgs:
for tc in m.get("tool_calls") or []:
call_ids.append(tc.get("id"))
if m.get("role") == "tool":
result_ids.append(m.get("tool_call_id"))
if sorted(filter(None, call_ids)) != sorted(filter(None, result_ids)):
problems.append(
f"{tag}: los tool_call_id no son una biyeccion "
f"(llamadas={call_ids} resultados={result_ids})"
)
if len(set(call_ids)) != len(call_ids):
problems.append(f"{tag}: tool_call_id duplicados: {call_ids}")
# ---- assistant: reasoning_content y content --------------------------------
for i, m in enumerate(msgs):
if m.get("role") != "assistant":
continue
if not (m.get("reasoning_content") or "").strip():
problems.append(f"{tag}: turno assistant #{i} sin reasoning_content")
if m.get("tool_calls") and (m.get("content") or "") != "":
problems.append(
f"{tag}: turno assistant #{i} tiene tool_calls y content no vacio "
f"(el content visible va en el ULTIMO assistant, sin tool_calls)"
)
if msgs[-1].get("role") != "assistant" or msgs[-1].get("tool_calls"):
problems.append(f"{tag}: la trayectoria no cierra con un assistant sin tool_calls")
# ---- payloads de code ---------------------------------------------------------
# Los seeds de recuperacion de error (grupos A1 y D) TIENEN que contener el patron
# equivocado: es el material que ensena a diagnosticarlo. La excepcion no se declara
# a mano con una bandera en meta (eso seria un escape hatch que apaga el lint), se
# deriva mecanicamente: un payload puede traer un patron prohibido si y solo si
# (a) su tool result es un string de error de la allow-list, y
# (b) un payload POSTERIOR del mismo seed hace lo mismo SIN el patron.
# Es decir: el error tiene que haber sido real y tiene que haber sido corregido.
error_result_ids = {
m.get("tool_call_id")
for m in msgs
if m.get("role") == "tool" and (m.get("content") or "").strip() in error_strings
}
ordered_calls = []
for m in msgs:
for tc in m.get("tool_calls") or []:
if tc.get("function", {}).get("name") == "execute_code":
args = tc.get("function", {}).get("arguments")
ordered_calls.append(
(tc.get("id"), args.get("code", "") if isinstance(args, dict) else "")
)
def is_corrected_mistake(call_id, rx):
"""El patron aparece en una llamada que fallo, y una llamada posterior lo evita."""
if call_id not in error_result_ids:
return False
seen = False
for cid, code_ in ordered_calls:
if seen and not rx.search(code_):
return True
if cid == call_id:
seen = True
return False
code_by_call = {}
for m in msgs:
for tc in m.get("tool_calls") or []:
fn = tc.get("function", {})
args = fn.get("arguments")
if not isinstance(args, dict):
problems.append(
f"{tag}: tool_calls.function.arguments tiene que ser un dict JSON, "
f"no {type(args).__name__}"
)
continue
if fn.get("name") == "high_level_overview":
n_overview_calls += 1
if fn.get("name") == "export_shape":
coverage["export_shape"] += 1
for k in args:
if k not in ("shapeId", "format", "mode"):
problems.append(
f"{tag}: export_shape con el argumento inventado '{k}' "
f"(el schema solo acepta shapeId/format/mode)"
)
if fn.get("name") != "execute_code":
continue
code = args.get("code", "")
code_by_call[tc.get("id")] = code
payloads.append((tag, code))
payload_texts.append(code)
# patrones prohibidos
for pname, rx, why in FORBIDDEN:
if not rx.search(code):
continue
if is_corrected_mistake(tc.get("id"), rx):
n_intentional_mistakes += 1
continue
for mt in rx.finditer(code):
frag = code[max(0, mt.start() - 30):mt.end() + 30].replace("\n", " ")
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)
and not GRID_APPEND_RE.search(code)):
problems.append(
f"{tag}: usa `X.appendChild(shape)` sin ninguna evidencia de flex en el "
f"payload. appendChild de 1 argumento solo es correcto en boards con "
f"flex; para un padre sin layout va "
f"parent.insertChild(parent.children.length, shape)."
)
# console.log de algo que tambien se retorna
ret_keys = extract_return_keys(code) or []
for logged in re.findall(r"console\.log\s*\(\s*([A-Za-z_$][\w$]*)", code):
if logged in ret_keys:
problems.append(
f"{tag}: console.log('{logged}') de algo que tambien se retorna "
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):
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}."
)
# cobertura
for key in ("addGridLayout", "addFlexLayout", "uploadMediaUrl",
"borderRadius", "fillColorGradient"):
if key in code:
coverage[key] += 1
if re.search(r"shadows\s*=", code):
coverage["shadows"] += 1
if re.search(r"layoutChild\s*\.\s*horizontalSizing", code):
coverage["layoutChild.horizontalSizing"] += 1
if "library.local" in code:
coverage["library.local"] += 1
# ---- tool results -------------------------------------------------------------
for m in msgs:
if m.get("role") != "tool":
continue
name = m.get("name")
content = m.get("content") or ""
cid = m.get("tool_call_id")
if name in ("penpot_api_info", "high_level_overview"):
# subconjunto de lineas de la captura, en orden original
lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()]
missing = [ln for ln in lines if ln not in doc_lines]
if missing:
problems.append(
f"{tag}: resultado de {name} con {len(missing)} linea(s) ausentes de "
f"penpot_api_docs.md -- documentacion FABRICADA. Primera: "
f"{missing[0][:110]!r}"
)
else:
last = -1
for ln in lines:
positions = [i for i in doc_index[ln] if i > last]
if not positions:
problems.append(
f"{tag}: resultado de {name} con lineas fuera del orden "
f"original de la captura (en {ln[:70]!r})"
)
break
last = positions[0]
elif name == "execute_code":
code = code_by_call.get(cid)
stripped = content.strip()
looks_json = stripped.startswith("{") or stripped.startswith("[")
if not looks_json:
# tiene que ser un error real de la allow-list
if stripped not in error_strings:
problems.append(
f"{tag}: resultado de execute_code que no es JSON y no esta en "
f"penpot_errors.md: {stripped[:110]!r}"
)
else:
try:
parsed = json.loads(stripped)
except json.JSONDecodeError as e:
problems.append(f"{tag}: resultado de execute_code con JSON invalido: {e}")
parsed = None
if isinstance(parsed, dict) and code is not None:
ret_keys = extract_return_keys(code)
if ret_keys is not None:
if sorted(parsed.keys()) != sorted(ret_keys):
problems.append(
f"{tag}: las claves del resultado {sorted(parsed.keys())} "
f"no coinciden con las del `return {{...}}` del payload "
f"{sorted(ret_keys)}"
)
# ids de juguete
for val in re.findall(r'"([^"]{1,24})"', stripped):
if TOY_ID_RE.fullmatch(val):
warnings.append(
f"{tag}: id con forma de juguete {val!r} en un tool result; "
f"los ids reales tienen forma de UUIDv4"
)
for val in re.findall(r'"(?:id|shapeId|boardId|rootId)"\s*:\s*"([^"]+)"', stripped):
if not UUID4_RE.match(val):
problems.append(
f"{tag}: id {val!r} en un tool result no tiene forma de UUIDv4 "
f"-- entrena al modelo a esperar ids de juguete"
)
# R2 en tool results: solo el grupo de reparacion puede traer grises
if grupo != GREY_INPUT_GROUP:
for hx in HEX_RE.findall(content):
if is_placeholder_grey(hx):
problems.append(
f"{tag}: gris de placeholder {hx} en un tool result del grupo "
f"{grupo}; solo el grupo {GREY_INPUT_GROUP} puede traerlos, "
f"como estado inicial a reparar"
)
# ---- chequeos globales -----------------------------------------------------------------
dupes = [c for c, n in Counter(payload_texts).items() if n > 1]
if dupes:
problems.append(
f"[GLOBAL] {len(dupes)} payload(s) de `code` duplicados exactos. Primero: "
f"{dupes[0][:120]!r}"
)
unique_payloads = len(set(payload_texts))
is_full_corpus = len(targets) == 1 and targets[0].resolve() == DEFAULT_TARGET.resolve()
print(f"\n=== SEEDS: {n_seeds} | payloads de code: {len(payload_texts)} "
f"(unicos: {unique_payloads}) ===")
print(f"=== linea mas larga: {max_line_len} caracteres ===")
print(f"=== grupos: {dict(sorted(grupo_counts.items()))} ===")
share = (n_with_system / n_seeds) if n_seeds else 0
print(f"=== seeds con mensaje system: {n_with_system}/{n_seeds} ({share:.0%}) ===")
print(f"=== errores intencionales (patron prohibido + error real + correccion posterior): "
f"{n_intentional_mistakes} ===")
print("=== cobertura por categoria ===")
for key, minimum in sorted(COVERAGE_MIN.items()):
got = coverage[key]
mark = "ok " if got >= minimum else "BAJO"
print(f" [{mark}] {key}: {got} (minimo {minimum})")
if is_full_corpus:
for key, minimum in COVERAGE_MIN.items():
if coverage[key] < minimum:
problems.append(
f"[COBERTURA] '{key}': {coverage[key]} seeds, minimo {minimum}. Un corpus a "
f"medio autorar reproduce el desbalance actual sin que nada lo note."
)
if unique_payloads < MIN_UNIQUE_PAYLOADS:
problems.append(
f"[GLOBAL] solo {unique_payloads} payloads unicos de execute_code, minimo "
f"{MIN_UNIQUE_PAYLOADS} (hoy el dataset viejo tiene 36)"
)
if not SYSTEM_SHARE_RANGE[0] <= share <= SYSTEM_SHARE_RANGE[1]:
problems.append(
f"[GLOBAL] share de seeds con mensaje system = {share:.0%}, fuera del rango "
f"{SYSTEM_SHARE_RANGE[0]:.0%}-{SYSTEM_SHARE_RANGE[1]:.0%} (objetivo ~30%)"
)
if n_overview_calls > MAX_HIGH_LEVEL_OVERVIEW_CALLS:
problems.append(
f"[GLOBAL] {n_overview_calls} llamadas a high_level_overview, maximo "
f"{MAX_HIGH_LEVEL_OVERVIEW_CALLS} (su propia descripcion prohibe llamarlo dos veces)"
)
else:
print("[INFO] modo parcial (no se lintea el corpus completo): los umbrales globales de "
"cobertura, unicidad y share de system NO se aplican")
problems.extend(node_check(payloads))
if warnings:
print(f"\n=== {len(warnings)} ADVERTENCIA(S) ===")
for w in warnings[:40]:
print(f" ! {w}")
if problems:
print(f"\n=== {len(problems)} PROBLEMA(S) ===")
for p in problems:
print(f" - {p}")
print(f"\n[LINT FAIL] {len(problems)} problema(s)")
sys.exit(1)
print("\n[LINT OK] 0 problemas")
sys.exit(0)
if __name__ == "__main__":
main()
+180 -23
View File
@@ -1,20 +1,63 @@
"""Fase 3: entrena el LoRA de Qwen3.6-35B-A3B sobre data/train.jsonl / data/eval.jsonl. """Entrena un LoRA de Qwen3.6-35B-A3B sobre un par train/eval en formato JSONL.
Corre DENTRO del contenedor `qwen-lora-train` en spark (necesita transformers/peft/accelerate Corre DENTRO del contenedor `qwen-lora-train` en spark (necesita transformers/peft/accelerate
ya instalados ahi, y el checkpoint base en MODEL_PATH). Invocar via: ya instalados ahi -- ver requirements.train.txt -- y el checkpoint base en MODEL_PATH).
docker exec qwen-lora-train python3 /workspace/ai-projects/qwen3-6-lora/scripts/10_train.py docker exec qwen-lora-train python3 /workspace/ai-projects/qwen3-6-lora/scripts/10_train.py
Tope de pasos para el dry-run via env var MAX_STEPS (o --max-steps N), sin tocar el resto de
la config de TrainingArguments.
Masking manual (no trl.SFTTrainer): usa data/chat_template_train.jinja (con tags Masking manual (no trl.SFTTrainer): usa data/chat_template_train.jinja (con tags
{% generation %}) para que tokenizer.apply_chat_template devuelva assistant_masks, y arma {% generation %}) para que tokenizer.apply_chat_template devuelva assistant_masks, y arma
labels = input_ids donde assistant_masks==1, -100 en el resto (nunca entrena sobre labels = input_ids donde assistant_masks==1, -100 en el resto (nunca entrena sobre
system/user/tool). system/user/tool).
CONFIGURACION POR ENV (Fase 6)
------------------------------
Todo lo que la Fase 6 necesita variar es una env var, y **todos los defaults son los valores
exactos de la Fase 3**: una corrida pelada (`docker exec ... 10_train.py`, sin ninguna env)
sigue reproduciendo la Fase 3 bit a bit. Eso es deliberado -- `out/lora-adapter/` es la
procedencia del modelo que esta hoy en produccion y tiene que seguir siendo reproducible.
MODEL_PATH checkpoint base (def: .../Qwen--Qwen3.6-35B-A3B)
TRAIN_FILE jsonl de entrenamiento (def: data/train.jsonl)
EVAL_FILE jsonl de evaluacion (def: data/eval.jsonl)
OUTPUT_DIR destino del adapter (def: out/lora-adapter)
CHAT_TEMPLATE .jinja de training (def: data/chat_template_train.jinja)
LEARNING_RATE (def: 1e-4) NUM_EPOCHS (def: 2)
LORA_R (def: 32) LORA_ALPHA (def: 64) LORA_DROPOUT (def: 0.05)
EVAL_STEPS (def: 50) SAVE_STEPS (def: 50)
GRAD_ACCUM (def: 16)
MAX_TOKENS tope duro de longitud (def: sin tope)
PRESERVE_THINKING '1' para conservar el thinking de turnos previos (def: off)
ALLOW_OVERWRITE '1' para permitir escribir sobre un OUTPUT_DIR existente (def: off)
MAX_STEPS tope de pasos para el smoke run (o --max-steps N)
Invocacion de la Fase 6 (LoRA #2 de diseno en Penpot):
TRAIN_FILE=data/train_lora2.jsonl EVAL_FILE=data/eval_lora2.jsonl \
OUTPUT_DIR=out/lora-adapter-penpot \
MODEL_PATH=/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16 \
LEARNING_RATE=3e-5 NUM_EPOCHS=3 EVAL_STEPS=25 SAVE_STEPS=25 \
MAX_TOKENS=3000 PRESERVE_THINKING=1 \
python3 scripts/10_train.py
TRES GUARDS QUE EXISTEN POR UNA RAZON CONCRETA
---------------------------------------------
1. **Guard de sobrescritura.** OUTPUT_DIR estaba hardcodeado a `out/lora-adapter`. Una corrida
de la Fase 6 con el default habria pisado el adapter de la Fase 3 -- el unico artefacto que
hace bit-reproducible el modelo en produccion. Ahora aborta salvo ALLOW_OVERWRITE=1.
2. **MAX_TOKENS aborta, no trunca.** Este script no tenia filtro de longitud ni truncaba nunca:
una trayectoria de diseno de 6k tokens revienta el presupuesto de memoria a mitad de corrida,
horas adentro. Truncar seria peor que abortar, porque cortaria targets del assistant en
silencio y entrenaria sobre una respuesta mutilada sin que nada lo indique.
3. **Asserts de rsLoRA/DoRA.** 20_merge_lora.py calcula `scaling = lora_alpha / r`. rsLoRA usa
`alpha/sqrt(r)`, asi que un adapter con use_rslora=True se mergearia con una escala
silenciosamente equivocada (2.0 donde va 11.3) **y pasaria todas las aserciones del merge**.
Se asertan aca, en el origen, donde todavia es barato.
""" """
import argparse import argparse
import json
import os import os
from collections import defaultdict
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
@@ -27,11 +70,34 @@ from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
def _path_env(name, default_rel):
"""Resuelve una ruta por env; las relativas cuelgan de la raiz del repo."""
raw = os.environ.get(name)
if not raw:
return REPO_ROOT / default_rel
p = Path(raw)
return p if p.is_absolute() else REPO_ROOT / p
MODEL_PATH = os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen--Qwen3.6-35B-A3B") MODEL_PATH = os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen--Qwen3.6-35B-A3B")
TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja" TRAIN_CHAT_TEMPLATE_PATH = _path_env("CHAT_TEMPLATE", "data/chat_template_train.jinja")
TRAIN_FILE = REPO_ROOT / "data" / "train.jsonl" TRAIN_FILE = _path_env("TRAIN_FILE", "data/train.jsonl")
EVAL_FILE = REPO_ROOT / "data" / "eval.jsonl" EVAL_FILE = _path_env("EVAL_FILE", "data/eval.jsonl")
OUTPUT_DIR = REPO_ROOT / "out" / "lora-adapter" OUTPUT_DIR = _path_env("OUTPUT_DIR", "out/lora-adapter")
LEARNING_RATE = float(os.environ.get("LEARNING_RATE", "1e-4"))
NUM_EPOCHS = float(os.environ.get("NUM_EPOCHS", "2"))
LORA_R = int(os.environ.get("LORA_R", "32"))
LORA_ALPHA = int(os.environ.get("LORA_ALPHA", "64"))
LORA_DROPOUT = float(os.environ.get("LORA_DROPOUT", "0.05"))
EVAL_STEPS = int(os.environ.get("EVAL_STEPS", "50"))
SAVE_STEPS = int(os.environ.get("SAVE_STEPS", "50"))
GRAD_ACCUM = int(os.environ.get("GRAD_ACCUM", "16"))
MAX_TOKENS = int(os.environ["MAX_TOKENS"]) if os.environ.get("MAX_TOKENS") else None
PRESERVE_THINKING = os.environ.get("PRESERVE_THINKING", "").lower() in ("1", "true", "yes")
ALLOW_OVERWRITE = os.environ.get("ALLOW_OVERWRITE", "").lower() in ("1", "true", "yes")
TARGET_MODULES = [ TARGET_MODULES = [
"q_proj", "k_proj", "v_proj", "o_proj", "q_proj", "k_proj", "v_proj", "o_proj",
@@ -50,17 +116,61 @@ def parse_args():
return args return args
def load_examples(tokenizer, path): def print_banner(args):
import json print("=" * 78)
print("CONFIGURACION DE ESTA CORRIDA")
print("=" * 78)
for label, value in [
("MODEL_PATH", MODEL_PATH),
("TRAIN_FILE", TRAIN_FILE),
("EVAL_FILE", EVAL_FILE),
("OUTPUT_DIR", OUTPUT_DIR),
("CHAT_TEMPLATE", TRAIN_CHAT_TEMPLATE_PATH),
("learning_rate", LEARNING_RATE),
("num_train_epochs", NUM_EPOCHS),
("grad_accum", GRAD_ACCUM),
("lora r / alpha / dropout", f"{LORA_R} / {LORA_ALPHA} / {LORA_DROPOUT}"),
("lora scaling (alpha/r)", LORA_ALPHA / LORA_R),
("eval_steps / save_steps", f"{EVAL_STEPS} / {SAVE_STEPS}"),
("MAX_TOKENS", MAX_TOKENS if MAX_TOKENS else "(sin tope)"),
("PRESERVE_THINKING", PRESERVE_THINKING),
("MAX_STEPS", args.max_steps if args.max_steps else "(corrida completa)"),
]:
print(f" {label:28} {value}")
print("=" * 78)
def guard_output_dir():
"""Abortar si OUTPUT_DIR ya tiene un adapter. Ver nota 1 del docstring."""
adapter = OUTPUT_DIR / "adapter_model.safetensors"
if adapter.exists() and not ALLOW_OVERWRITE:
raise SystemExit(
f"[ABORT] {adapter} ya existe.\n"
f" Este directorio contiene un adapter entrenado. Sobrescribirlo destruiria "
f"la procedencia del modelo que produjo.\n"
f" Si de verdad queres pisarlo, corre con ALLOW_OVERWRITE=1. Si lo que queres "
f"es entrenar un adapter nuevo, pasa OUTPUT_DIR=out/<otro-nombre>."
)
def load_examples(tokenizer, path, label):
"""Tokeniza el jsonl y arma los labels enmascarados. Aborta (no trunca, no filtra) si algun
ejemplo supera MAX_TOKENS: ver nota 2 del docstring."""
input_ids_list = [] input_ids_list = []
labels_list = [] labels_list = []
mask_by_bucket = defaultdict(lambda: {"assistant": 0, "total": 0, "n": 0})
too_long = []
lengths = []
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
for line in f: for lineno, line in enumerate(f, start=1):
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
example = json.loads(line) example = json.loads(line)
template_kwargs = {}
if PRESERVE_THINKING:
template_kwargs["preserve_thinking"] = True
rendered = tokenizer.apply_chat_template( rendered = tokenizer.apply_chat_template(
example["messages"], example["messages"],
tools=example.get("tools"), tools=example.get("tools"),
@@ -68,14 +178,53 @@ def load_examples(tokenizer, path):
return_assistant_tokens_mask=True, return_assistant_tokens_mask=True,
return_dict=True, return_dict=True,
add_generation_prompt=False, add_generation_prompt=False,
**template_kwargs,
) )
input_ids = rendered["input_ids"] input_ids = rendered["input_ids"]
assistant_masks = rendered["assistant_masks"] assistant_masks = rendered["assistant_masks"]
if sum(assistant_masks) == 0: if sum(assistant_masks) == 0:
raise AssertionError(f"assistant_masks vacia para un ejemplo de {path}") raise AssertionError(f"assistant_masks vacia en {path.name}:{lineno}")
n_tokens = len(input_ids)
lengths.append(n_tokens)
if MAX_TOKENS and n_tokens > MAX_TOKENS:
too_long.append((lineno, n_tokens))
continue
bucket = example.get("meta", {}).get("bucket", "sin_bucket")
stats = mask_by_bucket[bucket]
stats["assistant"] += sum(assistant_masks)
stats["total"] += n_tokens
stats["n"] += 1
labels = [tok if mask == 1 else -100 for tok, mask in zip(input_ids, assistant_masks)] labels = [tok if mask == 1 else -100 for tok, mask in zip(input_ids, assistant_masks)]
input_ids_list.append(input_ids) input_ids_list.append(input_ids)
labels_list.append(labels) labels_list.append(labels)
if too_long:
preview = ", ".join(f"linea {ln} ({n} tok)" for ln, n in too_long[:10])
more = f" (y {len(too_long) - 10} mas)" if len(too_long) > 10 else ""
raise SystemExit(
f"[ABORT] {len(too_long)} ejemplo(s) de {path.name} superan MAX_TOKENS={MAX_TOKENS}: "
f"{preview}{more}\n"
f" Se aborta a proposito en vez de truncar: truncar cortaria targets del "
f"assistant en silencio.\n"
f" Parti esas trayectorias en dos (persistiendo ids en `storage`), o subi "
f"MAX_TOKENS si tenes presupuesto de memoria para el pico que implica."
)
lengths.sort()
if lengths:
def pct(p):
return lengths[min(len(lengths) - 1, int(len(lengths) * p))]
print(f"[INFO] {label}: {len(lengths)} ejemplos | tokens p50={pct(0.5)} "
f"p90={pct(0.9)} p99={pct(0.99)} max={lengths[-1]}")
print(f"[INFO] ratio de mascara de assistant por bucket ({label}):")
for bucket, s in sorted(mask_by_bucket.items()):
ratio = 100.0 * s["assistant"] / s["total"] if s["total"] else 0.0
print(f" {bucket:24} n={s['n']:5} assistant/total = {ratio:5.1f}%")
return Dataset.from_dict({"input_ids": input_ids_list, "labels": labels_list}) return Dataset.from_dict({"input_ids": input_ids_list, "labels": labels_list})
@@ -104,6 +253,8 @@ class DataCollatorForCausalLMWithMasking:
def main(): def main():
args = parse_args() args = parse_args()
print_banner(args)
guard_output_dir()
print(f"[INFO] cargando tokenizer desde {MODEL_PATH}") print(f"[INFO] cargando tokenizer desde {MODEL_PATH}")
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH) tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
@@ -112,9 +263,9 @@ def main():
tokenizer.pad_token = tokenizer.eos_token tokenizer.pad_token = tokenizer.eos_token
print(f"[INFO] tokenizando {TRAIN_FILE}") print(f"[INFO] tokenizando {TRAIN_FILE}")
train_dataset = load_examples(tokenizer, TRAIN_FILE) train_dataset = load_examples(tokenizer, TRAIN_FILE, "train")
print(f"[INFO] tokenizando {EVAL_FILE}") print(f"[INFO] tokenizando {EVAL_FILE}")
eval_dataset = load_examples(tokenizer, EVAL_FILE) eval_dataset = load_examples(tokenizer, EVAL_FILE, "eval")
print(f"[INFO] train={len(train_dataset)} eval={len(eval_dataset)}") print(f"[INFO] train={len(train_dataset)} eval={len(eval_dataset)}")
print(f"[INFO] cargando modelo desde {MODEL_PATH}") print(f"[INFO] cargando modelo desde {MODEL_PATH}")
@@ -126,12 +277,18 @@ def main():
lora_config = LoraConfig( lora_config = LoraConfig(
target_modules=TARGET_MODULES, target_modules=TARGET_MODULES,
r=32, r=LORA_R,
lora_alpha=64, lora_alpha=LORA_ALPHA,
lora_dropout=0.05, lora_dropout=LORA_DROPOUT,
task_type="CAUSAL_LM", task_type="CAUSAL_LM",
bias="none", bias="none",
) )
# Ver nota 3 del docstring: rsLoRA/DoRA romperian la aritmetica del merge en silencio.
assert getattr(lora_config, "use_rslora", False) is False, "use_rslora tiene que quedar en False"
assert getattr(lora_config, "use_dora", False) is False, "use_dora tiene que quedar en False"
assert lora_config.bias == "none", "lora bias tiene que quedar en 'none'"
assert not lora_config.modules_to_save, "modules_to_save tiene que quedar vacio"
model = get_peft_model(model, lora_config) model = get_peft_model(model, lora_config)
model.print_trainable_parameters() model.print_trainable_parameters()
@@ -147,19 +304,19 @@ def main():
training_args = TrainingArguments( training_args = TrainingArguments(
output_dir=str(OUTPUT_DIR), output_dir=str(OUTPUT_DIR),
num_train_epochs=2, num_train_epochs=NUM_EPOCHS,
per_device_train_batch_size=1, per_device_train_batch_size=1,
gradient_accumulation_steps=16, gradient_accumulation_steps=GRAD_ACCUM,
gradient_checkpointing=True, gradient_checkpointing=True,
bf16=True, bf16=True,
optim="adamw_8bit", optim="adamw_8bit",
learning_rate=1e-4, learning_rate=LEARNING_RATE,
lr_scheduler_type="cosine", lr_scheduler_type="cosine",
warmup_ratio=0.03, warmup_ratio=0.03,
eval_strategy="steps", eval_strategy="steps",
eval_steps=50, eval_steps=EVAL_STEPS,
save_strategy="steps", save_strategy="steps",
save_steps=50, save_steps=SAVE_STEPS,
save_total_limit=3, save_total_limit=3,
logging_steps=5, logging_steps=5,
max_steps=args.max_steps if args.max_steps else -1, max_steps=args.max_steps if args.max_steps else -1,