Live probing against Penpot 2.16.2 (repeated, on a dedicated scratch page, cleaned up afterwards) shows board.flex.appendChild(shape) is a real function, distinct from board.appendChild, arity 1, throws nothing, and preserves visual insertion order -- unlike board.appendChild, which still inserts at the front as documented. The prior "broken" claim traces to the MCP server's own high_level_overview() text (CRITICAL / BROKEN / NEVER use), never verified with a live throw, and possibly confused with a real doc bug that mislabels the grid 3-arg example as board.flex. Removes flex.appendChild from the lint's FORBIDDEN patterns and from the gate 5 forbidden-behavior veto, updates PENPOT_API_VERIFIED.md with the verified facts, and drops the now-obsolete pattern from the replay filters in 05_build_dataset.py and 07_build_lora2_mix.py.
804 lines
33 KiB
Python
804 lines
33 KiB
Python
"""Fase 2 (revisado en Fase 6): ensambla un train/eval jsonl a partir de los seeds escritos a
|
|
mano en <SEEDS_DIR>/*.jsonl mas data/raw/replay.jsonl.
|
|
|
|
Corre localmente (no requiere GPU ni el modelo). Aplica variacion deterministica
|
|
(random.seed(42)) sobre los seeds para llegar al volumen objetivo por bucket sin duplicar
|
|
lineas exactas. Verifica que ningun seed mencione la skill held-out (spark-ssh). Tagea cada
|
|
ejemplo con meta.bucket, mezcla, y separa 90/10 train/eval estratificado por bucket.
|
|
|
|
PARAMETROS POR ENV (los defaults reproducen la invocacion historica de Fase 2)
|
|
-----------------------------------------------------------------------------
|
|
SEEDS_DIR directorio de seeds (def: data/raw/seeds)
|
|
REPLAY_FILE jsonl de replay de Fase 1 (def: data/raw/replay.jsonl)
|
|
TRAIN_OUT destino del split de train (def: data/train.jsonl)
|
|
EVAL_OUT destino del split de eval (def: data/eval.jsonl)
|
|
ALLOW_OVERWRITE '1' para permitir pisar un destino que ya existe (def: off)
|
|
|
|
Las rutas relativas se resuelven contra la raiz del repo.
|
|
|
|
REGLA CRITICA: NO REGENERAR data/train.jsonl NI data/eval.jsonl
|
|
---------------------------------------------------------------
|
|
Esos dos archivos son la procedencia exacta del modelo que hoy esta en produccion y el
|
|
baseline de la puerta 1 (perdida ponderada 0.2750). Por eso hay un guard que ABORTA si el
|
|
destino ya existe, salvo que se pase ALLOW_OVERWRITE=1. Los datasets nuevos se construyen a
|
|
archivos nuevos:
|
|
|
|
TRAIN_OUT=data/train_v2.jsonl EVAL_OUT=data/eval_v2.jsonl python3 scripts/05_build_dataset.py
|
|
|
|
Y ademas: regenerar NO es idempotente. `stratified_split()` mezcla con un unico RNG
|
|
(random.Random(42)) sobre la lista concatenada de todos los buckets, asi que cambiar el
|
|
tamano de UN bucket (p.ej. BUCKET_TARGETS['penpot'] de 110 a 320) corre la secuencia de
|
|
numeros aleatorios y reshufflea el split de TODOS los buckets. Volver a correr este script
|
|
con los targets de hoy NO reconstruye el train.jsonl de Fase 2 aunque los seeds no hayan
|
|
cambiado.
|
|
|
|
AUGMENTACION (reescrita en Fase 6)
|
|
----------------------------------
|
|
La version anterior tenia dos defectos medidos sobre el train.jsonl de Fase 2:
|
|
|
|
1. `perturb_value()` reescribia SOLO `tool_calls.function.arguments`, nunca los tool results
|
|
ni el `content` final del assistant -> 30 ejemplos auto-contradictorios (la llamada decia
|
|
`issue_number: 82` y la respuesta final decia "issue #77"). El modelo se entrenaba a
|
|
ignorar sus propios argumentos.
|
|
2. La inyeccion de prefijos de parafraseo se pegaba delante de cualquier turno de usuario, sin
|
|
mirar su forma gramatical -> 68 prompts rotos del tipo
|
|
"Necesito que ¿Podes usar el tool de import_image...".
|
|
|
|
Reemplazos:
|
|
|
|
- La sustitucion de valores es ATOMICA y se declara en el seed (`meta.variation`): se aplica
|
|
sobre TODOS los campos de texto del ejemplo a la vez (arguments, tool results, contents,
|
|
reasoning_content), de modo que un valor que cambia, cambia en todas partes. Sin
|
|
`meta.variation` no se sustituye ningun valor -- adivinar que numero de un string es
|
|
"seguro" de perturbar es exactamente lo que producia las contradicciones.
|
|
- `NEVER_PERTURB_KEYS` protege los valores que jamas pueden variar (`code`, `shapeId`,
|
|
`tool_call_id`, `id`). Un payload de Penpot perturbado es codigo roto. Si un valor de
|
|
`meta.variation` aparece dentro de un campo protegido, la build FALLA: seria una variante
|
|
internamente inconsistente, justo lo que veniamos a eliminar.
|
|
- El bucket `penpot` esta exento de sustitucion de valores por completo (NO_PERTURB_BUCKETS):
|
|
sus payloads son codigo, y la variacion tiene que venir de seeds distintos.
|
|
- `meta.paraphrases` (2-3 reescrituras a mano del turno del usuario) es la via PREFERIDA de
|
|
variacion del prompt. La inyeccion de prefijos queda solo como fallback y solo cuando el
|
|
turno arranca con un imperativo que sabemos conjugar; cualquier otra forma se deja intacta.
|
|
|
|
`meta.variation` y `meta.paraphrases` son entradas de build: no se escriben al dataset final.
|
|
|
|
FORMATO DE meta.variation
|
|
-------------------------
|
|
Dos formas equivalentes, las dos deterministas por indice de variante:
|
|
|
|
"meta": {"variation": {"82": ["77", "91"], "billing/pricing.py": ["core/rates.py"]}}
|
|
"meta": {"variation": [{"82": "77"}, {"82": "91"}]}
|
|
|
|
La primera es un mapa token -> lista de reemplazos (la variante i usa el elemento
|
|
(i-1) % len). La segunda es una lista de mapas completos, uno por variante.
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
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
|
|
|
|
|
|
SEEDS_DIR = _path_env("SEEDS_DIR", "data/raw/seeds")
|
|
REPLAY_PATH = _path_env("REPLAY_FILE", "data/raw/replay.jsonl")
|
|
TRAIN_PATH = _path_env("TRAIN_OUT", "data/train.jsonl")
|
|
EVAL_PATH = _path_env("EVAL_OUT", "data/eval.jsonl")
|
|
ALLOW_OVERWRITE = os.environ.get("ALLOW_OVERWRITE", "").lower() in ("1", "true", "yes")
|
|
|
|
SEED = 42
|
|
EVAL_FRACTION = 0.10
|
|
HELD_OUT_SKILL = "spark-ssh"
|
|
|
|
# Volumen objetivo por bucket. El bucket "replay" no se varia -- se usa tal cual viene de la
|
|
# Fase 1. `penpot` sube de 110 a 320 en Fase 6 (el corpus de diseno reescrito).
|
|
BUCKET_TARGETS = {
|
|
"penpot": 320,
|
|
"otros_mcps": 290,
|
|
"skills_adherencia": 160,
|
|
"delegacion_subagentes": 65,
|
|
"negativos": 70,
|
|
"manejo_errores": 55,
|
|
}
|
|
|
|
# Claves cuyo valor NUNCA se sustituye, en ningun bucket. `code` es un payload de JavaScript
|
|
# (perturbarlo produce codigo roto); los ids son referencias que tienen que seguir cerrando
|
|
# entre la llamada y su resultado.
|
|
NEVER_PERTURB_KEYS = {"code", "shapeId", "tool_call_id", "id"}
|
|
|
|
# Buckets exentos de sustitucion de valores por completo. Su variacion viene de seeds
|
|
# distintos, no de reescribir strings.
|
|
NO_PERTURB_BUCKETS = {"penpot"}
|
|
|
|
# Anti-colapso del bucket penpot: como esta exento de sustitucion, la unica fuente de
|
|
# variacion son `meta.paraphrases` y seeds distintos. Si un corpus llega a 320 filas
|
|
# repitiendo 96 trayectorias identicas, la augmentacion no augmenta nada (es lo que pasaba en
|
|
# Fase 2: 99 filas -> 40 trayectorias unicas sobre 41 seeds). Se exige >= 2.5 trayectorias
|
|
# unicas por seed, que es justo lo que dan 2-3 parafrasis a mano por seed mas el original.
|
|
PENPOT_UNIQUE_RATIO = float(os.environ.get("PENPOT_UNIQUE_RATIO", "2.5"))
|
|
|
|
# El assert duro que mata los 68 prompts rotos de Fase 2: ningun turno de usuario del dataset
|
|
# final puede tener un prefijo de parafraseo pegado delante de una pregunta ya formada.
|
|
BROKEN_PREFIX_RE = re.compile(r"(?:Necesito que|Necesito|Por favor,|Che,|Dale,|¿Pod[eé]s)\s*¿")
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Parafraseo por prefijo (FALLBACK -- la via preferida es meta.paraphrases)
|
|
# ------------------------------------------------------------------------------------------
|
|
# Cada prefijo declara que forma verbal exige. Un prefijo solo se aplica si podemos convertir
|
|
# el primer verbo del turno a esa forma; si no, el turno se deja intacto. Preferimos perder
|
|
# variacion antes que emitir un prompt agramatical: el modelo aprende la forma del prompt.
|
|
# imperativo -> el turno ya esta en imperativo, solo baja la mayuscula ("Por favor, crea ...")
|
|
# infinitivo -> "Necesito crear ..." (declarativa, funciona con multiples oraciones)
|
|
# pregunta -> "¿Podes crear ...?" (solo si el turno es UNA oracion afirmativa)
|
|
PARAPHRASE_PREFIXES = [
|
|
("", None),
|
|
("Por favor, ", "imperativo"),
|
|
("Che, ", "imperativo"),
|
|
("Dale, ", "imperativo"),
|
|
("Necesito ", "infinitivo"),
|
|
("¿Podés ", "pregunta"),
|
|
]
|
|
|
|
# Imperativos con cambio de raiz: no se derivan mecanicamente, van a mano.
|
|
IRREGULAR_IMPERATIVE_TO_INFINITIVE = {
|
|
"cierra": "cerrar",
|
|
"empieza": "empezar",
|
|
"encuentra": "encontrar",
|
|
"cuenta": "contar",
|
|
"muestra": "mostrar",
|
|
"prueba": "probar",
|
|
"mueve": "mover",
|
|
"vuelve": "volver",
|
|
"recuerda": "recordar",
|
|
"corrige": "corregir",
|
|
"repite": "repetir",
|
|
"pide": "pedir",
|
|
"sigue": "seguir",
|
|
"consigue": "conseguir",
|
|
"convierte": "convertir",
|
|
"invierte": "invertir",
|
|
"sugiere": "sugerir",
|
|
"elige": "elegir",
|
|
"dame": "darme",
|
|
"da": "dar",
|
|
"haz": "hacer",
|
|
"ve": "ver",
|
|
"ten": "tener",
|
|
"pon": "poner",
|
|
"sal": "salir",
|
|
"di": "decir",
|
|
"sube": "subir",
|
|
"abre": "abrir",
|
|
"reabre": "reabrir",
|
|
"escribe": "escribir",
|
|
"describe": "describir",
|
|
"revierte": "revertir",
|
|
"anade": "anadir",
|
|
"añade": "añadir",
|
|
"responde": "responder",
|
|
"corre": "correr",
|
|
"lee": "leer",
|
|
"mete": "meter",
|
|
"rompe": "romper",
|
|
"vende": "vender",
|
|
"aprende": "aprender",
|
|
"borra": "borrar",
|
|
}
|
|
|
|
ENCLITIC_PRONOUNS = ("melo", "mela", "selo", "sela", "nos", "me", "lo", "la", "los", "las", "le", "les")
|
|
|
|
FIRST_WORD_RE = re.compile(r"^([A-Za-zÁÉÍÓÚÜÑáéíóúüñ]+)(.*)$", re.S)
|
|
|
|
|
|
def strip_accent(word):
|
|
table = str.maketrans("áéíóúÁÉÍÓÚ", "aeiouAEIOU")
|
|
return word.translate(table)
|
|
|
|
|
|
def has_enclitic(infinitive):
|
|
"""'dame' -> 'darme', 'mostrame' -> 'mostrarme': el infinitivo hereda el pronombre, que
|
|
despues de 'Necesito ' suena mal ('Necesito darme el resumen'). Con '¿Podés ' es correcto."""
|
|
return any(infinitive.endswith("r" + pron) for pron in ENCLITIC_PRONOUNS)
|
|
|
|
|
|
def to_infinitive(word):
|
|
"""Convierte un imperativo (tuteo o voseo, con o sin enclitico) a infinitivo.
|
|
|
|
Devuelve None si no estamos seguros -- el llamador entonces NO aplica el prefijo. Es
|
|
deliberado: un imperativo desconocido produce un prompt agramatical, y el costo de
|
|
perder una variante es mucho menor que el de entrenar sobre castellano roto.
|
|
"""
|
|
lower = word.lower()
|
|
|
|
enclitic = ""
|
|
base = lower
|
|
for pron in ENCLITIC_PRONOUNS:
|
|
if len(lower) > len(pron) + 2 and lower.endswith(pron):
|
|
base, enclitic = lower[: -len(pron)], pron
|
|
break
|
|
|
|
if lower in IRREGULAR_IMPERATIVE_TO_INFINITIVE:
|
|
return IRREGULAR_IMPERATIVE_TO_INFINITIVE[lower]
|
|
if base in IRREGULAR_IMPERATIVE_TO_INFINITIVE:
|
|
return IRREGULAR_IMPERATIVE_TO_INFINITIVE[base] + enclitic
|
|
|
|
# Voseo: acento en la ultima vocal (crea' -> crear, hace' -> hacer, subi' -> subir).
|
|
if base and base[-1] in "áéí":
|
|
return strip_accent(base) + "r" + enclitic
|
|
|
|
# Tuteo regular de la primera conjugacion: crea -> crear, busca -> buscar.
|
|
if len(base) >= 4 and base.endswith("a"):
|
|
return base + "r" + enclitic
|
|
|
|
# Todo lo demas (terminaciones en -e, que pueden ser -er o -ir, y cualquier otra forma)
|
|
# es ambiguo: no adivinamos.
|
|
return None
|
|
|
|
|
|
def apply_prefix(content, prefix, mode):
|
|
"""Aplica un prefijo de parafraseo respetando la gramatica. Devuelve None si no se puede."""
|
|
if not prefix:
|
|
return content
|
|
stripped = content.strip()
|
|
if not stripped or stripped[0] in "¿¡":
|
|
# Nunca pegar un prefijo delante de una pregunta/exclamacion ya formada. Este es el
|
|
# caso exacto que producia "Necesito que ¿Podes usar el tool de import_image...".
|
|
return None
|
|
|
|
m = FIRST_WORD_RE.match(stripped)
|
|
if not m:
|
|
return None
|
|
first, rest = m.group(1), m.group(2)
|
|
|
|
if mode == "imperativo":
|
|
infinitive = to_infinitive(first)
|
|
if infinitive is None:
|
|
# Si no reconocemos un imperativo, el turno probablemente es declarativo
|
|
# ("El ci-developer que lance devolvio BLOCKED...") y "Por favor, el ci-developer
|
|
# que..." tampoco es castellano. No se aplica prefijo.
|
|
return None
|
|
return prefix + first.lower() + rest
|
|
|
|
if mode == "infinitivo":
|
|
infinitive = to_infinitive(first)
|
|
if infinitive is None or has_enclitic(infinitive):
|
|
return None
|
|
return prefix + infinitive + rest
|
|
|
|
if mode == "pregunta":
|
|
infinitive = to_infinitive(first)
|
|
if infinitive is None:
|
|
return None
|
|
# Solo si es UNA oracion afirmativa: convertirla en pregunta exige tocar el cierre.
|
|
if "?" in stripped or "!" in stripped or "\n" in stripped:
|
|
return None
|
|
if not stripped.endswith(".") or stripped.count(".") != 1:
|
|
return None
|
|
return prefix + infinitive + rest[:-1] + "?"
|
|
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Patrones prohibidos: la fuente de verdad es scripts/07_lint_penpot_code.py
|
|
# ------------------------------------------------------------------------------------------
|
|
# Se IMPORTAN de ahi (no se copian) para que no puedan divergir. Si el lint renombra una
|
|
# entrada, este script falla ruidosamente en vez de dejar de chequear en silencio.
|
|
FORBIDDEN_PATTERN_NAMES = [
|
|
"findShapeById con 2 argumentos",
|
|
"propiedad .layout inexistente",
|
|
"import_image / importImage / createImage / filePath",
|
|
"textAlign",
|
|
"typography.setFont",
|
|
]
|
|
LINT_PATH = Path(__file__).resolve().parent / "07_lint_penpot_code.py"
|
|
|
|
|
|
def load_forbidden_patterns():
|
|
spec = importlib.util.spec_from_file_location("penpot_lint", LINT_PATH)
|
|
if spec is None or spec.loader is None:
|
|
print(f"[ERROR] no se pudo importar {LINT_PATH}")
|
|
sys.exit(1)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
by_name = {name: rx for name, rx, _why in module.FORBIDDEN}
|
|
missing = [n for n in FORBIDDEN_PATTERN_NAMES if n not in by_name]
|
|
if missing:
|
|
print(
|
|
f"[ERROR] {LINT_PATH.name} ya no define estos patrones: {missing}. "
|
|
f"Actualizar FORBIDDEN_PATTERN_NAMES en este script (la fuente de verdad de los "
|
|
f"patrones es el lint, no esta copia de nombres)."
|
|
)
|
|
sys.exit(1)
|
|
return [(n, by_name[n]) for n in FORBIDDEN_PATTERN_NAMES]
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Carga
|
|
# ------------------------------------------------------------------------------------------
|
|
def load_jsonl(path):
|
|
lines = []
|
|
with open(path, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
lines.append(json.loads(line))
|
|
return lines
|
|
|
|
|
|
def load_seeds():
|
|
by_bucket = {}
|
|
if not SEEDS_DIR.exists():
|
|
print(f"[ERROR] {SEEDS_DIR} no existe")
|
|
sys.exit(1)
|
|
for path in sorted(SEEDS_DIR.glob("*.jsonl")):
|
|
examples = load_jsonl(path)
|
|
for ex in examples:
|
|
bucket = ex.get("meta", {}).get("bucket")
|
|
if bucket is None:
|
|
print(f"[ERROR] {path}: ejemplo sin meta.bucket")
|
|
sys.exit(1)
|
|
by_bucket.setdefault(bucket, []).append(ex)
|
|
print(f"[INFO] {path.name}: {len(examples)} ejemplos cargados")
|
|
return by_bucket
|
|
|
|
|
|
def assert_no_held_out_skill(by_bucket):
|
|
for bucket, examples in by_bucket.items():
|
|
for ex in examples:
|
|
blob = json.dumps(ex, ensure_ascii=False)
|
|
assert HELD_OUT_SKILL not in blob, (
|
|
f"[ASSERT FAIL] bucket '{bucket}' menciona la skill held-out "
|
|
f"'{HELD_OUT_SKILL}' -- esta prohibido en todos los buckets"
|
|
)
|
|
print(f"[OK] ninguna mencion de la skill held-out '{HELD_OUT_SKILL}' en los seeds")
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Sustitucion atomica de valores
|
|
# ------------------------------------------------------------------------------------------
|
|
def resolve_variation(meta, variant_idx):
|
|
"""Devuelve el mapa token -> reemplazo para esta variante, o {} si el seed no declara
|
|
variacion. variant_idx >= 1 (la variante 0 es el seed sin tocar)."""
|
|
variation = meta.get("variation")
|
|
if not variation:
|
|
return {}
|
|
if isinstance(variation, list):
|
|
chosen = variation[(variant_idx - 1) % len(variation)]
|
|
if not isinstance(chosen, dict):
|
|
raise AssertionError(
|
|
"[ASSERT FAIL] meta.variation como lista tiene que contener dicts "
|
|
"token -> reemplazo"
|
|
)
|
|
return {str(k): str(v) for k, v in chosen.items()}
|
|
if isinstance(variation, dict):
|
|
mapping = {}
|
|
for token, options in variation.items():
|
|
if not isinstance(options, list) or not options:
|
|
raise AssertionError(
|
|
f"[ASSERT FAIL] meta.variation['{token}'] tiene que ser una lista no vacia "
|
|
f"de reemplazos"
|
|
)
|
|
mapping[str(token)] = str(options[(variant_idx - 1) % len(options)])
|
|
return mapping
|
|
raise AssertionError("[ASSERT FAIL] meta.variation tiene que ser un dict o una lista")
|
|
|
|
|
|
def substitute_atomic(example, mapping):
|
|
"""Aplica el mapa de sustitucion a TODOS los campos de texto del ejemplo a la vez.
|
|
|
|
Un valor que cambia, cambia en todas partes: arguments (incluidos los escalares JSON no
|
|
string: `issue_number: 82` es tan parte del ejemplo como el "#82" del texto), tool
|
|
results, content y reasoning_content. Los valores bajo NEVER_PERTURB_KEYS quedan
|
|
intactos, y si el token original sobrevive en alguno de ellos se lanza -- seria una
|
|
variante auto-contradictoria, exactamente el defecto que esta reescritura vino a eliminar.
|
|
"""
|
|
if not mapping:
|
|
return json.loads(json.dumps(example, ensure_ascii=False))
|
|
|
|
pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
|
|
survivors = []
|
|
hits = Counter()
|
|
|
|
def walk(node, protected):
|
|
if isinstance(node, dict):
|
|
return {
|
|
key: walk(value, protected or key in NEVER_PERTURB_KEYS)
|
|
for key, value in node.items()
|
|
}
|
|
if isinstance(node, list):
|
|
return [walk(item, protected) for item in node]
|
|
if isinstance(node, str):
|
|
if protected:
|
|
for token in mapping:
|
|
if token in node:
|
|
survivors.append(token)
|
|
return node
|
|
|
|
def _sub(m):
|
|
hits[m.group(0)] += 1
|
|
return mapping[m.group(0)]
|
|
|
|
return pattern.sub(_sub, node)
|
|
if isinstance(node, (int, float)) and not isinstance(node, bool):
|
|
# Un escalar JSON (issue_number: 82) se sustituye por igualdad exacta de su
|
|
# representacion, y se devuelve con el mismo tipo. Si no se hiciera, la llamada
|
|
# diria 82 y el texto 77: el defecto de Fase 2.
|
|
key = repr(node) if isinstance(node, float) else str(node)
|
|
if key in mapping:
|
|
if protected:
|
|
survivors.append(key)
|
|
return node
|
|
hits[key] += 1
|
|
replacement = mapping[key]
|
|
try:
|
|
return type(node)(replacement)
|
|
except ValueError:
|
|
return replacement
|
|
return node
|
|
return node
|
|
|
|
varied = walk(example, False)
|
|
|
|
if survivors:
|
|
raise AssertionError(
|
|
f"[ASSERT FAIL] los valores {sorted(set(survivors))} de meta.variation aparecen "
|
|
f"dentro de un campo protegido ({sorted(NEVER_PERTURB_KEYS)}) y no se pueden "
|
|
f"sustituir ahi. La variante quedaria auto-contradictoria: sacar esos valores de "
|
|
f"meta.variation o reescribir el seed."
|
|
)
|
|
unused = [token for token in mapping if hits[token] == 0]
|
|
if unused:
|
|
raise AssertionError(
|
|
f"[ASSERT FAIL] los tokens {unused} de meta.variation no aparecen en el ejemplo "
|
|
f"(typo en el seed): la sustitucion no haria nada"
|
|
)
|
|
return varied
|
|
|
|
|
|
def strip_build_only_meta(example):
|
|
"""meta.variation y meta.paraphrases son entradas de build, no datos de entrenamiento."""
|
|
meta = example.get("meta")
|
|
if isinstance(meta, dict):
|
|
meta.pop("variation", None)
|
|
meta.pop("paraphrases", None)
|
|
return example
|
|
|
|
|
|
def vary_example(example, rng, variant_idx, bucket):
|
|
"""Produce una variante determinista del ejemplo.
|
|
|
|
variant_idx == 0 devuelve el seed tal cual (los seeds originales son parte del volumen).
|
|
Para variant_idx > 0:
|
|
1. reescritura del turno del usuario: meta.paraphrases si el seed las trae, y si no,
|
|
inyeccion de un prefijo gramaticalmente compatible como fallback,
|
|
2. sustitucion atomica de valores segun meta.variation (salvo buckets exentos).
|
|
|
|
El orden importa: la sustitucion va DESPUES de la parafrasis para que un valor citado en
|
|
la parafrasis a mano ("Pasame el issue 82") tambien se sustituya. Al reves, la parafrasis
|
|
reintroduciria el valor viejo y la variante volveria a ser auto-contradictoria.
|
|
"""
|
|
varied = json.loads(json.dumps(example, ensure_ascii=False))
|
|
if variant_idx == 0:
|
|
return strip_build_only_meta(varied)
|
|
|
|
meta = varied.get("meta", {}) or {}
|
|
paraphrases = meta.get("paraphrases") or []
|
|
first_user = next((m for m in varied.get("messages", []) if m.get("role") == "user"), None)
|
|
|
|
if first_user is not None:
|
|
if paraphrases:
|
|
# Via preferida: reescrituras a mano del turno del usuario.
|
|
first_user["content"] = paraphrases[(variant_idx - 1) % len(paraphrases)]
|
|
else:
|
|
prefix, mode = rng.choice(PARAPHRASE_PREFIXES)
|
|
rewritten = apply_prefix(first_user["content"], prefix, mode)
|
|
if rewritten is not None:
|
|
first_user["content"] = rewritten
|
|
|
|
mapping = {} if bucket in NO_PERTURB_BUCKETS else resolve_variation(meta, variant_idx)
|
|
# Se limpia meta ANTES de sustituir: si no, un token que solo aparece en meta.paraphrases
|
|
# contaria como "usado" y el chequeo de tokens muertos dejaria de servir.
|
|
strip_build_only_meta(varied)
|
|
return substitute_atomic(varied, mapping)
|
|
|
|
|
|
def build_bucket(bucket, seeds, target_count):
|
|
rng = random.Random(f"{SEED}-{bucket}")
|
|
n_seeds = len(seeds)
|
|
if n_seeds == 0:
|
|
print(f"[WARN] bucket '{bucket}' no tiene seeds, se omite")
|
|
return []
|
|
if target_count <= n_seeds:
|
|
print(f"[WARN] bucket '{bucket}': target ({target_count}) <= seeds ({n_seeds}), se usan solo los seeds originales")
|
|
return [vary_example(ex, rng, 0, bucket) for ex in seeds]
|
|
|
|
result = []
|
|
variant_idx = 0
|
|
seed_order = list(range(n_seeds))
|
|
while len(result) < target_count:
|
|
if variant_idx > 0:
|
|
rng.shuffle(seed_order)
|
|
for i in seed_order:
|
|
if len(result) >= target_count:
|
|
break
|
|
result.append(vary_example(seeds[i], rng, variant_idx, bucket))
|
|
variant_idx += 1
|
|
print(f"[INFO] bucket '{bucket}': {n_seeds} seeds -> {len(result)} ejemplos ({variant_idx} pasadas de variacion)")
|
|
return result
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Asserts sobre el corpus ensamblado
|
|
# ------------------------------------------------------------------------------------------
|
|
def trajectory_key(example):
|
|
"""Identidad de la trayectoria: messages + tools, sin meta."""
|
|
return json.dumps(
|
|
{"messages": example.get("messages"), "tools": example.get("tools")},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def iter_code_calls(example):
|
|
"""(tool_call_id, code) de cada llamada a execute_code, en orden de aparicion."""
|
|
for msg in example.get("messages", []) or []:
|
|
for tool_call in msg.get("tool_calls") or []:
|
|
args = tool_call.get("function", {}).get("arguments")
|
|
if isinstance(args, dict) and isinstance(args.get("code"), str):
|
|
yield tool_call.get("id"), args["code"]
|
|
|
|
|
|
def iter_code_payloads(example):
|
|
for _call_id, code in iter_code_calls(example):
|
|
yield code
|
|
|
|
|
|
def assert_penpot_not_collapsed(examples, n_seeds):
|
|
"""El bucket penpot esta exento de sustitucion de valores, asi que si la augmentacion no
|
|
produce trayectorias realmente distintas, 320 filas son 96 ejemplos repetidos. En Fase 2
|
|
esto era 99 filas -> 40 trayectorias unicas sobre 41 seeds."""
|
|
rows = [ex for ex in examples if ex.get("meta", {}).get("bucket") == "penpot"]
|
|
if not rows:
|
|
return
|
|
unique_trajectories = len({trajectory_key(ex) for ex in rows})
|
|
unique_code = len({code for ex in rows for code in iter_code_payloads(ex)})
|
|
minimum = PENPOT_UNIQUE_RATIO * n_seeds
|
|
print(
|
|
f"[INFO] penpot: {len(rows)} filas, {unique_trajectories} trayectorias unicas, "
|
|
f"{unique_code} payloads de code unicos, {n_seeds} seeds "
|
|
f"(minimo exigido: {minimum:.0f} trayectorias unicas)"
|
|
)
|
|
assert unique_trajectories >= minimum, (
|
|
f"[ASSERT FAIL] el bucket penpot colapso: {unique_trajectories} trayectorias unicas "
|
|
f"sobre {n_seeds} seeds (minimo {minimum:.0f} = {PENPOT_UNIQUE_RATIO} x seeds). La "
|
|
f"augmentacion no esta augmentando nada: agregar meta.paraphrases a los seeds o "
|
|
f"escribir seeds nuevos. Perturbar los payloads NO es una opcion (son codigo)."
|
|
)
|
|
# El bucket esta exento de sustitucion: los payloads de code del corpus generado tienen
|
|
# que ser exactamente los de los seeds, ni uno mas ni uno menos.
|
|
assert unique_code > 0, "[ASSERT FAIL] el bucket penpot no tiene ningun payload de code"
|
|
|
|
|
|
AUTHORED_PREFIX_LEN = 40
|
|
|
|
|
|
def authored_user_openings(by_bucket, replay_examples):
|
|
"""Los primeros caracteres de cada turno de usuario escrito a mano (seeds, parafrasis a
|
|
mano y replay). 'Che, ¿que significa X?' es castellano perfectamente valido cuando lo
|
|
escribio una persona; lo que esta prohibido es que lo produzca nuestra inyeccion de
|
|
prefijos. Se comparan solo las primeras letras para que una sustitucion de valores mas
|
|
adelante en la frase no rompa la comparacion."""
|
|
openings = set()
|
|
|
|
def add(text):
|
|
if isinstance(text, str) and text:
|
|
openings.add(text[:AUTHORED_PREFIX_LEN])
|
|
|
|
sources = list(replay_examples)
|
|
for examples in by_bucket.values():
|
|
sources.extend(examples)
|
|
for ex in sources:
|
|
for paraphrase in (ex.get("meta", {}) or {}).get("paraphrases") or []:
|
|
add(paraphrase)
|
|
for msg in ex.get("messages", []) or []:
|
|
if msg.get("role") == "user":
|
|
add(msg.get("content") or "")
|
|
return openings
|
|
|
|
|
|
def assert_no_broken_prefixes(examples, authored_openings):
|
|
"""El assert que mata los 68 prompts rotos: cero turnos de usuario con un prefijo de
|
|
parafraseo pegado delante de una pregunta ya formada."""
|
|
offenders = []
|
|
for ex in examples:
|
|
for msg in ex.get("messages", []) or []:
|
|
if msg.get("role") != "user":
|
|
continue
|
|
content = msg.get("content") or ""
|
|
if BROKEN_PREFIX_RE.search(content):
|
|
if content[:AUTHORED_PREFIX_LEN] in authored_openings:
|
|
continue # escrito a mano, no inyectado
|
|
offenders.append(content[:120])
|
|
assert not offenders, (
|
|
f"[ASSERT FAIL] {len(offenders)} turno(s) de usuario con un prefijo de parafraseo "
|
|
f"pegado delante de una pregunta ya formada. Primero: {offenders[0]!r}"
|
|
)
|
|
print("[OK] 0 turnos de usuario con prefijo de parafraseo agramatical")
|
|
|
|
|
|
def error_result_call_ids(example):
|
|
"""tool_call_id cuyo resultado NO es JSON, es decir, un string de error real."""
|
|
ids = set()
|
|
for msg in example.get("messages", []) or []:
|
|
if msg.get("role") != "tool":
|
|
continue
|
|
content = (msg.get("content") or "").strip()
|
|
if content and not content.startswith(("{", "[")):
|
|
ids.add(msg.get("tool_call_id"))
|
|
return ids
|
|
|
|
|
|
def is_corrected_mistake(example, call_id, rx):
|
|
"""Mismo criterio que 07_lint_penpot_code.py: un payload puede traer un patron prohibido
|
|
si y solo si (a) su tool result fue un error real y (b) una llamada POSTERIOR del mismo
|
|
ejemplo hace lo mismo SIN el patron. O sea: el error ocurrio de verdad y fue corregido.
|
|
Es el material de los grupos A1 y D (auto-correccion desde errores reales)."""
|
|
if call_id not in error_result_call_ids(example):
|
|
return False
|
|
seen = False
|
|
for cid, code in iter_code_calls(example):
|
|
if seen and not rx.search(code):
|
|
return True
|
|
if cid == call_id:
|
|
seen = True
|
|
return False
|
|
|
|
|
|
def assert_no_forbidden_patterns(examples, patterns):
|
|
"""Cero ocurrencias de la API prohibida de Penpot en el corpus ensamblado.
|
|
|
|
Alcance: los payloads de `code` (donde la API se INVOCA de verdad), de todos los buckets.
|
|
La prosa (reasoning_content, content, tool results) queda deliberadamente fuera: un seed
|
|
tiene que poder nombrar la forma equivocada para advertir contra ella, y la puerta que
|
|
mide la prosa es otra. La fuente de verdad de los patrones es 07_lint_penpot_code.py.
|
|
"""
|
|
problems = []
|
|
for idx, ex in enumerate(examples):
|
|
bucket = ex.get("meta", {}).get("bucket", "sin_bucket")
|
|
for call_id, code in iter_code_calls(ex):
|
|
for name, rx in patterns:
|
|
if not rx.search(code):
|
|
continue
|
|
if is_corrected_mistake(ex, call_id, rx):
|
|
continue
|
|
for m in rx.finditer(code):
|
|
line_start = code.rfind("\n", 0, m.start()) + 1
|
|
line_end = code.find("\n", m.end())
|
|
line = code[line_start: line_end if line_end != -1 else len(code)]
|
|
# La clave `layout` SI existe en la salida de shapeStructure(): esas
|
|
# lineas no son la propiedad inexistente del shape.
|
|
if "shapeStructure" in line:
|
|
continue
|
|
problems.append(
|
|
f"ejemplo #{idx} (bucket={bucket}): [{name}] ...{line.strip()[:120]}..."
|
|
)
|
|
assert not problems, (
|
|
f"[ASSERT FAIL] {len(problems)} ocurrencia(s) de API prohibida de Penpot en los "
|
|
f"payloads de code del corpus ensamblado (fuente de verdad de los patrones: "
|
|
f"{LINT_PATH.name}):\n - " + "\n - ".join(problems[:20])
|
|
)
|
|
print(f"[OK] 0 ocurrencias de los {len(patterns)} patrones prohibidos de Penpot en los payloads de code")
|
|
|
|
|
|
def guard_output(path):
|
|
if path.exists() and not ALLOW_OVERWRITE:
|
|
print(
|
|
f"[ABORT] {path} ya existe.\n"
|
|
f" data/train.jsonl y data/eval.jsonl son la procedencia exacta del modelo "
|
|
f"en produccion y el baseline de la puerta 1: regenerarlos los destruye, y ademas "
|
|
f"NO es idempotente (ver el docstring).\n"
|
|
f" Escribi a archivos nuevos con TRAIN_OUT=... EVAL_OUT=..., o corre con "
|
|
f"ALLOW_OVERWRITE=1 si de verdad queres pisarlo."
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def stratified_split(all_examples, rng):
|
|
by_bucket = {}
|
|
for ex in all_examples:
|
|
by_bucket.setdefault(ex["meta"]["bucket"], []).append(ex)
|
|
|
|
train, eval_ = [], []
|
|
for bucket, examples in by_bucket.items():
|
|
shuffled = examples[:]
|
|
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 '{bucket}': {len(shuffled)} total -> train={len(shuffled) - n_eval} eval={n_eval}")
|
|
|
|
rng.shuffle(train)
|
|
rng.shuffle(eval_)
|
|
return train, eval_
|
|
|
|
|
|
def write_jsonl(path, examples):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
for ex in examples:
|
|
f.write(json.dumps(ex, ensure_ascii=False))
|
|
f.write("\n")
|
|
f.flush()
|
|
|
|
|
|
def main():
|
|
print("=" * 78)
|
|
print("CONFIGURACION DE ESTA BUILD")
|
|
print("=" * 78)
|
|
for label, value in [
|
|
("SEEDS_DIR", SEEDS_DIR),
|
|
("REPLAY_FILE", REPLAY_PATH),
|
|
("TRAIN_OUT", TRAIN_PATH),
|
|
("EVAL_OUT", EVAL_PATH),
|
|
("ALLOW_OVERWRITE", ALLOW_OVERWRITE),
|
|
("BUCKET_TARGETS", BUCKET_TARGETS),
|
|
]:
|
|
print(f" {label:18} {value}")
|
|
print("=" * 78)
|
|
|
|
guard_output(TRAIN_PATH)
|
|
guard_output(EVAL_PATH)
|
|
|
|
forbidden_patterns = load_forbidden_patterns()
|
|
|
|
by_bucket = load_seeds()
|
|
assert_no_held_out_skill(by_bucket)
|
|
|
|
all_examples = []
|
|
for bucket, target_count in BUCKET_TARGETS.items():
|
|
seeds = by_bucket.get(bucket, [])
|
|
all_examples.extend(build_bucket(bucket, seeds, target_count))
|
|
|
|
assert_penpot_not_collapsed(all_examples, len(by_bucket.get("penpot", [])))
|
|
|
|
replay_examples = load_jsonl(REPLAY_PATH)
|
|
for ex in replay_examples:
|
|
ex.setdefault("meta", {})["bucket"] = "replay"
|
|
print(f"[INFO] bucket 'replay': {len(replay_examples)} ejemplos (sin variacion, tal cual Fase 1)")
|
|
all_examples.extend(replay_examples)
|
|
|
|
print(f"[INFO] total combinado: {len(all_examples)} ejemplos")
|
|
|
|
assert_no_broken_prefixes(all_examples, authored_user_openings(by_bucket, replay_examples))
|
|
assert_no_forbidden_patterns(all_examples, forbidden_patterns)
|
|
|
|
split_rng = random.Random(SEED)
|
|
train, eval_ = stratified_split(all_examples, split_rng)
|
|
|
|
write_jsonl(TRAIN_PATH, train)
|
|
write_jsonl(EVAL_PATH, eval_)
|
|
|
|
print(f"[OK] {TRAIN_PATH} escrito: {len(train)} ejemplos")
|
|
print(f"[OK] {EVAL_PATH} escrito: {len(eval_)} ejemplos")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|