Phase 6: train a second LoRA for real Penpot UI design capability #5
+10
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user