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