Phase 6: train a second LoRA for real Penpot UI design capability #5

Merged
aleleba merged 36 commits from agente-fase6-lora2-penpot into master 2026-08-04 21:36:07 -06:00
5 changed files with 973 additions and 30 deletions
Showing only changes of commit c65d309719 - Show all commits
+113
View File
@@ -81,6 +81,119 @@ services:
retries: 5
start_period: 600s
# Fase 6 -- candidato v2 (base + LoRA #1 + LoRA #2 de diseno en Penpot, mergeado
# y recuantizado a NVFP4). Clon 1:1 de vllm-eval-nvfp4, que a su vez clona el
# compose real de produccion, incluido --speculative-config: es el contenedor
# contra el que se corren las puertas 2/3/4/5 del candidato, y solo sirve si
# replica exactamente los flags con los que se va a servir. Respecto de
# vllm-eval-nvfp4 cambian SOLO container_name, puerto (8004; 8000 produccion,
# 8001 Fase 4, 8002/8003 Fase 5), volumen, --model y --served-model-name.
# Nunca se toca vllm-qwen36 ni su compose real de Portainer.
vllm-eval-nvfp4-v2:
image: vllm/vllm-openai:cu130-nightly-aarch64
container_name: vllm-eval-nvfp4-v2
restart: "no"
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
ports:
- "8004:8000"
ipc: host
ulimits:
memlock: -1
stack: 67108864
volumes:
- /home/aleleba/ft-models/Qwen3.6-35B-A3B-mcp-v2-NVFP4:/models/Qwen3.6-35B-A3B-mcp-v2-NVFP4:ro
command: >
--model /models/Qwen3.6-35B-A3B-mcp-v2-NVFP4
--served-model-name qwen3.6-35b-a3b-mcp-v2-nvfp4
--host 0.0.0.0
--port 8000
--tensor-parallel-size 1
--trust-remote-code
--quantization compressed-tensors
--moe-backend flashinfer_cutlass
--kv-cache-dtype fp8_e4m3
--gpu-memory-utilization 0.45
--max-model-len 524288
--max-num-seqs 8
--max-num-batched-tokens 32768
--enable-chunked-prefill
--enable-prefix-caching
--speculative-config '{"method":"mtp","num_speculative_tokens":1}'
--reasoning-parser qwen3
--tool-call-parser qwen3_coder
--enable-auto-tool-choice
--default-chat-template-kwargs '{"preserve_thinking":true}'
--limit-mm-per-prompt '{"image":4,"video":0,"audio":0}'
--generation-config vllm
--override-generation-config '{"temperature":0.6,"top_p":0.80,"top_k":20,"presence_penalty":0.0,"repetition_penalty":1.0}'
--hf-overrides '{"text_config":{"rope_scaling":{"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":262144}}}'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 600s
# Fase 6 -- diagnostico de aislamiento del drift del head de MTP (riesgo #10):
# identico a vllm-eval-nvfp4-v2 pero SIN --speculative-config. El draft head de
# MTP se copia del linaje base y nunca se fine-tunea, mientras que el target
# model ya derivo DOS veces (LoRA #1 y LoRA #2), asi que la tasa de aceptacion
# del speculative decoding puede caer y degradar la calidad servida sin que la
# cuantizacion ni el dataset tengan nada que ver. Correr las mismas puertas en
# 8004 (spec) y 8005 (nospec) separa las dos causas. Es un servicio de
# diagnostico: no clona produccion 1:1 a proposito (esa es justo la variable
# que se esta aislando) y los flags de produccion NUNCA se cambian por esto,
# solo se reporta el hallazgo.
vllm-eval-nvfp4-v2-nospec:
image: vllm/vllm-openai:cu130-nightly-aarch64
container_name: vllm-eval-nvfp4-v2-nospec
restart: "no"
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
ports:
- "8005:8000"
ipc: host
ulimits:
memlock: -1
stack: 67108864
volumes:
- /home/aleleba/ft-models/Qwen3.6-35B-A3B-mcp-v2-NVFP4:/models/Qwen3.6-35B-A3B-mcp-v2-NVFP4:ro
command: >
--model /models/Qwen3.6-35B-A3B-mcp-v2-NVFP4
--served-model-name qwen3.6-35b-a3b-mcp-v2-nvfp4-nospec
--host 0.0.0.0
--port 8000
--tensor-parallel-size 1
--trust-remote-code
--quantization compressed-tensors
--moe-backend flashinfer_cutlass
--kv-cache-dtype fp8_e4m3
--gpu-memory-utilization 0.45
--max-model-len 524288
--max-num-seqs 8
--max-num-batched-tokens 32768
--enable-chunked-prefill
--enable-prefix-caching
--reasoning-parser qwen3
--tool-call-parser qwen3_coder
--enable-auto-tool-choice
--default-chat-template-kwargs '{"preserve_thinking":true}'
--limit-mm-per-prompt '{"image":4,"video":0,"audio":0}'
--generation-config vllm
--override-generation-config '{"temperature":0.6,"top_p":0.80,"top_k":20,"presence_penalty":0.0,"repetition_penalty":1.0}'
--hf-overrides '{"text_config":{"rope_scaling":{"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":262144}}}'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 600s
# Fase 5 -- diagnostico de aislamiento: identico a vllm-eval-nvfp4 pero SIN
# --speculative-config, para determinar si la regresion de calidad observada
# en las puertas 2-3 (vs. Fase 4) viene del speculative decoding (MTP) o de
+370
View File
@@ -22,7 +22,23 @@ Algoritmo (opera directo sobre tensores crudos, nunca instancia el modelo):
5. Verificacion automatica: conteo de tensores igual; todo tensor no-target
byte-a-byte identico al base; todo tensor LoRA-target con delta no-cero;
sin NaN/Inf.
Soporta --dry-run (Fase 6): corre en ~2 segundos, SIN cargar los pesos del modelo
base (solo lee el adapter y el model.safetensors.index.json del checkpoint), y
asierte todo lo que, de estar mal, se descubriria recien despues de escribir 67 GB:
conteo de targets resueltos (310) y de claves del adapter (620), scaling, flags de
la variante de LoRA (rsLoRA/DoRA/bias/lora_bias/modules_to_save/rank_pattern/
alpha_pattern), presencia y conteo por familia de modulos, norma de lora_B por
familia, y que todas las claves remapeadas existan en el indice del checkpoint base.
Sale antes de escribir nada -- la ruta de merge real no se toca.
PRINCIPIO RECTOR del dry-run: ausente no es OK; ausente es "no se pudo verificar", y
eso tiene que fallar, nunca colarse como verde. Una clave que falta en
adapter_config.json (porque la entreno otra version de PEFT), una familia de modulos
que no aparece, o un conteo que nadie comparo son exactamente la forma en que este
chequeo produciria 67 GB con capacidad silenciosamente incompleta.
"""
import argparse
import gc
import json
import os
@@ -44,6 +60,48 @@ ADAPTER_PREFIX = "base_model.model."
LORA_A_SUFFIX = ".lora_A.weight"
LORA_B_SUFFIX = ".lora_B.weight"
# Invariantes del adapter esperados por el --dry-run. Son los de Fase 3 y los del
# LoRA #2 de Fase 6 (r/alpha sin cambios a proposito, ver PLAN.md): 310 modulos
# objetivo = 620 claves (lora_A + lora_B), con scaling = lora_alpha / r = 2.0.
#
# Los defaults literales estan aparte de los valores en uso a proposito: son
# overrideables por env y la corrida real hereda el env del contenedor, asi que si
# alguien exporta uno para "destrabar" una corrida, la asercion se vuelve tautologica.
# warn_expected_overrides() lo deja escrito en el log en vez de dejarlo pasar mudo.
DEFAULT_EXPECTED_TARGET_COUNT = 310
DEFAULT_EXPECTED_R = 32
DEFAULT_EXPECTED_LORA_ALPHA = 64
EXPECTED_TARGET_COUNT = int(os.environ.get("EXPECTED_TARGET_COUNT", str(DEFAULT_EXPECTED_TARGET_COUNT)))
EXPECTED_R = int(os.environ.get("EXPECTED_R", str(DEFAULT_EXPECTED_R)))
EXPECTED_LORA_ALPHA = int(os.environ.get("EXPECTED_LORA_ALPHA", str(DEFAULT_EXPECTED_LORA_ALPHA)))
EXPECTED_SCALING = EXPECTED_LORA_ALPHA / EXPECTED_R
# Desglose de los 310 targets por familia de modulos, derivado de los 12 sufijos de
# TARGET_MODULES (scripts/10_train.py) y de la topologia del modelo:
# linear_attn = 30 capas Gated DeltaNet x 5 sufijos
# (in_proj_qkv, in_proj_z, in_proj_a, in_proj_b, out_proj) = 150
# shared_expert = 40 capas MoE x 3 sufijos (gate_proj, up_proj, down_proj) = 120
# attention_qkvo = 10 capas de atencion completa x 4 (q/k/v/o_proj) = 40
# total = 310
# Se asierte PRESENCIA y CONTEO EXACTO de cada familia, no solo el total: un
# TARGET_MODULES mal escrito que no toque linear_attn y enganche otros 150 modulos
# deja el total en 310 y ninguna norma en cero (linear_attn simplemente no esta),
# asi que el chequeo de normas por familia no lo ve. La familia "otros" es el
# catch-all de module_family(): tiene que quedar VACIA -- cualquier cosa ahi es un
# modulo que nadie previo (por ejemplo mlp.gate, el router).
EXPECTED_FAMILY_COUNTS = {
"linear_attn": 150,
"shared_expert": 120,
"attention_qkvo": 40,
"otros": 0,
}
# Umbral relativo para "modulo efectivamente muerto": norma de lora_B por debajo de
# esta fraccion de la mediana de su familia. El chequeo de norm == 0.0 exacto atrapa
# el cero de la init de PEFT (riesgo #8), pero un modulo con norma 1e-12 pasaria
# igual de mudo y esta igual de muerto.
DEAD_MODULE_RELATIVE_THRESHOLD = 1e-6
# El adapter fue entrenado cargando el checkpoint con AutoModelForCausalLM, que expone las
# capas como "model.layers.N...."; el checkpoint base crudo (multimodal) las tiene bajo
# "model.language_model.layers.N....". Hay que remapear el nombre del tensor base antes de
@@ -206,11 +264,323 @@ def verify_chat_template_is_not_training_template():
)
def module_family(base_name):
"""Familia de modulos a la que pertenece un target del adapter. El orden importa:
shared_expert tiene sus propios gate/up/down_proj y linear_attn sus propias
proyecciones, asi que ambos se chequean antes que la atencion q/k/v/o."""
if ".linear_attn." in base_name:
return "linear_attn"
if "shared_expert" in base_name:
return "shared_expert"
if re.search(r"\.(q|k|v|o)_proj$", base_name):
return "attention_qkvo"
return "otros"
def warn_expected_overrides():
"""Deja escrito en el log si algun EXPECTED_* viene pisado por el env. La corrida
real hereda el env del contenedor: sin este aviso, alguien que exporta un valor
para destrabar una corrida convierte la asercion en tautologia y el log sigue
diciendo [OK] igual."""
for name, literal in (
("EXPECTED_TARGET_COUNT", DEFAULT_EXPECTED_TARGET_COUNT),
("EXPECTED_R", DEFAULT_EXPECTED_R),
("EXPECTED_LORA_ALPHA", DEFAULT_EXPECTED_LORA_ALPHA),
):
in_use = globals()[name]
if in_use != literal:
print(
f"[WARN] {name} overrideado por env (valor literal {literal}, en uso {in_use}) "
"-- esta asercion NO esta verificando el invariante del proyecto"
)
def check_adapter_config_flags(adapter_config, problems):
"""Verifica las flags de adapter_config.json que cambian la semantica del merge.
Ausente NO es OK: una clave que falta (por ejemplo porque el adapter se entreno
con otra version de PEFT que la omite) es "no se pudo verificar", y se reporta
como problema. Antes, un .get(clave, False) daba verde Y ADEMAS imprimia
"clave=False", o sea que el log afirmaba haber verificado algo que nunca estuvo.
"""
reportado = {}
def leer(clave):
"""Devuelve (valor, presente). Registra el problema si la clave no esta."""
if clave not in adapter_config:
problems.append(
f"clave {clave!r} ausente del adapter_config, no se puede verificar "
"(ausente != OK: puede venir de otra version de PEFT que la omite, "
"y el merge la ignoraria en silencio)"
)
reportado[clave] = "AUSENTE"
return None, False
reportado[clave] = adapter_config[clave]
return adapter_config[clave], True
# rsLoRA escala por lora_alpha/sqrt(r) en vez de lora_alpha/r: un adapter
# entrenado con rsLoRA se mergearia con 2.0 donde corresponde 11.3 y pasaria
# TODAS las demas aserciones sin decir nada.
valor, presente = leer("use_rslora")
if presente and valor:
problems.append("use_rslora=true -- el merge aplica lora_alpha/r, rsLoRA usa lora_alpha/sqrt(r)")
# DoRA agrega un vector de magnitud que la formula W + scaling * (B @ A) ignora.
valor, presente = leer("use_dora")
if presente and valor:
problems.append("use_dora=true -- el merge ignora el vector de magnitud de DoRA")
# bias: bias del modulo BASE entrenado junto al adapter; el merge no lo aplica.
valor, presente = leer("bias")
if presente and valor != "none":
problems.append(f"bias={valor!r} -- el merge no aplica biases entrenados")
# lora_bias (PEFT >= 0.14) es OTRO campo, distinto de `bias`: agrega un termino de
# bias DENTRO de lora_B, que W + scaling * (B @ A) tampoco contempla. Riesgo #6
# del PLAN.md lo pide explicitamente; el fallo silencioso es el mismo que rsLoRA.
valor, presente = leer("lora_bias")
if presente and valor:
problems.append(
f"lora_bias={valor!r} -- PEFT agrega un bias dentro de lora_B que la formula "
"W + scaling * (B @ A) del merge ignora por completo"
)
# modules_to_save quedarian fuera del merge y se perderian en silencio.
valor, presente = leer("modules_to_save")
if presente and valor:
problems.append(f"modules_to_save={valor!r} -- esos modulos no se mergean y se perderian")
# rank_pattern / alpha_pattern permiten r y lora_alpha POR MODULO. Si estan
# poblados, scaling no es uniformemente alpha/r, pero tanto el dry-run como el
# merge real aplican un unico escalar a los 310 tensores: las capas con otro r se
# mergearian con la escala equivocada mientras el log dice scaling=2.0 [OK].
for clave in ("rank_pattern", "alpha_pattern"):
valor, presente = leer(clave)
if presente and valor:
problems.append(
f"{clave}={valor!r} no esta vacio -- define r/lora_alpha por modulo, y el merge "
f"aplica un unico scaling={EXPECTED_SCALING} a todos los targets"
)
print("[INFO] flags de la variante de LoRA en adapter_config.json:")
for clave in ("use_rslora", "use_dora", "bias", "lora_bias", "modules_to_save", "rank_pattern", "alpha_pattern"):
print(f" {clave:16s} = {reportado[clave]!r}")
def dry_run():
"""Chequeo pre-merge de ~2 segundos: no carga los pesos del modelo base, solo el
adapter (~190MB) y el indice de shards del checkpoint. Aborta ante cualquier
inconsistencia ANTES de que el merge real escriba 67 GB."""
print("[INFO] --dry-run: no se escribe nada, no se cargan los pesos del modelo base")
warn_expected_overrides()
adapter_config = json.loads((ADAPTER_PATH / "adapter_config.json").read_text())
r = adapter_config["r"]
lora_alpha = adapter_config["lora_alpha"]
scaling = lora_alpha / r
print(f"[INFO] r={r} lora_alpha={lora_alpha} scaling={scaling}")
problems = []
if r != EXPECTED_R:
problems.append(f"r={r} (se esperaba {EXPECTED_R})")
if lora_alpha != EXPECTED_LORA_ALPHA:
problems.append(f"lora_alpha={lora_alpha} (se esperaba {EXPECTED_LORA_ALPHA})")
if scaling != EXPECTED_SCALING:
problems.append(f"scaling={scaling} (se esperaba {EXPECTED_SCALING})")
check_adapter_config_flags(adapter_config, problems)
# Targets del adapter + norma de lora_B por familia. PEFT inicializa lora_B en
# CERO EXACTO, asi que una familia con norma cero significa que esos modulos
# nunca recibieron gradiente: es un bug de ENTRENAMIENTO (learning rate, masking,
# target_modules), no del merge -- aunque el sintoma aparezca aca, como el
# AssertionError de "delta cero" que tira merge_shards().
families = {}
zero_modules = []
checkpoint_names = {}
with safe_open(str(ADAPTER_PATH / "adapter_model.safetensors"), framework="pt") as f:
keys = list(f.keys())
base_names = sorted(
k[len(ADAPTER_PREFIX):-len(LORA_A_SUFFIX)] for k in keys if k.endswith(LORA_A_SUFFIX)
)
for base_name in base_names:
key_b = f"{ADAPTER_PREFIX}{base_name}{LORA_B_SUFFIX}"
if key_b not in keys:
problems.append(f"falta {key_b} en el adapter (hay lora_A sin su lora_B)")
continue
norm = f.get_tensor(key_b).to(torch.float32).norm().item()
family = families.setdefault(
module_family(base_name), {"n": 0, "norms": [], "by_module": []}
)
family["n"] += 1
family["norms"].append(norm)
family["by_module"].append((base_name, norm))
if norm == 0.0:
zero_modules.append(base_name)
checkpoint_names[base_name] = remap_adapter_name_to_checkpoint_name(f"{base_name}.weight")
# El conteo de claves se ASIERTE, no solo se imprime: un adapter con
# modules_to_save (u otros tensores extra) daria "310 + 310 = 930", una linea
# aritmeticamente falsa que hoy pasaba en verde. El invariante del plan es
# 310 targets / 620 claves.
expected_keys = 2 * len(base_names)
print(
f"[INFO] {len(base_names)} claves lora_A + {len(base_names)} lora_B = {expected_keys} claves "
f"esperadas, {len(keys)} tensores presentes en el adapter"
)
if len(keys) != expected_keys:
extras = sorted(
k for k in keys if not (k.endswith(LORA_A_SUFFIX) or k.endswith(LORA_B_SUFFIX))
)
problems.append(
f"el adapter tiene {len(keys)} tensores pero {len(base_names)} pares lora_A/lora_B "
f"implican {expected_keys} claves -- hay {len(keys) - expected_keys} tensor(es) de "
f"diferencia. No-lora_A/B encontrados (hasta 10): {extras[:10]}"
)
if expected_keys != 2 * EXPECTED_TARGET_COUNT:
problems.append(
f"claves lora_A/lora_B = {expected_keys}, se esperaban {2 * EXPECTED_TARGET_COUNT} "
f"({EXPECTED_TARGET_COUNT} targets x 2)"
)
print(f"[INFO] targets resueltos: {len(checkpoint_names)} (se esperaban {EXPECTED_TARGET_COUNT})")
if len(checkpoint_names) != EXPECTED_TARGET_COUNT:
problems.append(
f"conteo de targets resueltos = {len(checkpoint_names)}, se esperaban {EXPECTED_TARGET_COUNT}"
)
# Presencia y conteo EXACTO por familia. El total correcto no alcanza: si
# target_modules deja de enganchar linear_attn y engancha otros 150 modulos, el
# total sigue dando 310 y ninguna norma es cero (la familia simplemente no
# aparece), asi que sin este chequeo el dry-run pasa en verde. Ausente no es OK.
print("[INFO] conteo de targets por familia de modulos (esperado vs encontrado):")
for family in sorted(set(EXPECTED_FAMILY_COUNTS) | set(families)):
expected_n = EXPECTED_FAMILY_COUNTS.get(family)
found_n = families.get(family, {}).get("n", 0)
expected_txt = "no prevista" if expected_n is None else str(expected_n)
estado = "OK" if expected_n == found_n else "MAL"
print(f" familia={family:16s} esperados={expected_txt:>11s} encontrados={found_n:4d} [{estado}]")
if expected_n is None:
problems.append(
f"familia {family!r} con {found_n} targets: no esta prevista en EXPECTED_FAMILY_COUNTS "
"-- son modulos que nadie previo y que el merge tocaria igual"
)
elif found_n != expected_n:
if found_n == 0:
problems.append(
f"familia {family}: AUSENTE del adapter (se esperaban {expected_n} targets). "
"Ausente no es OK: ninguna norma da cero porque la familia ni siquiera esta, "
"asi que el chequeo de normas no lo veria. Revisar TARGET_MODULES"
)
elif expected_n == 0:
ejemplos = [n for n in base_names if module_family(n) == family][:10]
problems.append(
f"familia {family}: {found_n} targets donde se esperaban 0 -- el catch-all de "
f"module_family() no debe atrapar nada. Ejemplos: {ejemplos}"
)
else:
problems.append(
f"familia {family}: {found_n} targets, se esperaban {expected_n}"
)
print("[INFO] norma de lora_B por familia de modulos:")
for family in sorted(families):
stats = families[family]
norms = stats["norms"]
print(
f" familia={family:16s} n={stats['n']:4d} "
f"norm_total={sum(norms):10.4f} norm_min={min(norms):.6f} "
f"norm_max={max(norms):.6f} norm_avg={sum(norms) / len(norms):.6f}"
)
if max(norms) == 0.0:
problems.append(
f"familia {family}: TODAS las normas de lora_B son cero -- esos modulos nunca "
"recibieron gradiente. Es un bug de ENTRENAMIENTO (learning rate, masking o "
"target_modules), NO del merge"
)
# "Efectivamente muerto", no solo cero exacto: un modulo con norma 1e-12 frente a
# una mediana de familia de 1e-1 no aporta nada al merge, pero norm == 0.0 (igualdad
# exacta de float) no lo atrapa.
dead_modules = []
for family, stats in families.items():
norms = sorted(stats["norms"])
median = norms[len(norms) // 2]
if median <= 0.0:
continue
floor = DEAD_MODULE_RELATIVE_THRESHOLD * median
for base_name, norm in stats["by_module"]:
if 0.0 < norm < floor:
dead_modules.append((base_name, family, norm, median))
if dead_modules:
print(f"[WARN] {len(dead_modules)} modulos con norma de lora_B efectivamente muerta:")
for base_name, family, norm, median in dead_modules[:20]:
print(f" {base_name} (familia={family}, norm={norm:.3e}, mediana de familia={median:.3e})")
if len(dead_modules) > 20:
print(f" ... y {len(dead_modules) - 20} mas")
problems.append(
f"{len(dead_modules)} modulos con ||lora_B|| < {DEAD_MODULE_RELATIVE_THRESHOLD:g} x la "
"mediana de su familia -- practicamente sin gradiente. Mismo diagnostico que la norma "
"cero: es un bug de ENTRENAMIENTO, no del merge"
)
if zero_modules:
print(f"[WARN] {len(zero_modules)} modulos con norma de lora_B EXACTAMENTE cero:")
for base_name in zero_modules[:20]:
print(f" {base_name}")
if len(zero_modules) > 20:
print(f" ... y {len(zero_modules) - 20} mas")
problems.append(
f"{len(zero_modules)} modulos con ||lora_B|| == 0 -- el merge abortaria con 'delta cero'. "
"Es un bug de ENTRENAMIENTO, no del merge"
)
# Que cada clave remapeada exista en el indice del checkpoint base: es el chequeo
# que evita descubrir un mismatch de nombres recien despues de escribir 67 GB.
index = json.loads((MODEL_PATH / "model.safetensors.index.json").read_text())
weight_map = index["weight_map"]
matched = [n for n in checkpoint_names.values() if n in weight_map]
unmatched = sorted(n for n in checkpoint_names.values() if n not in weight_map)
print(
f"[INFO] claves del adapter presentes en model.safetensors.index.json: "
f"{len(matched)}/{len(checkpoint_names)} ({len(weight_map)} tensores en el indice)"
)
if unmatched:
print(f"[ERROR] {len(unmatched)} claves remapeadas NO existen en el checkpoint base:")
for name in unmatched[:20]:
print(f" {name}")
if len(unmatched) > 20:
print(f" ... y {len(unmatched) - 20} mas")
problems.append(f"{len(unmatched)} claves remapeadas ausentes del indice del checkpoint base")
if problems:
raise AssertionError("dry-run FALLIDO:\n - " + "\n - ".join(problems))
print("[OK] dry-run: todas las aserciones pasaron, el merge real puede correr")
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"--dry-run",
action="store_true",
help=(
"verificar el adapter y el remapeo de claves contra el indice del checkpoint base "
"SIN cargar pesos ni escribir nada (~2s), en vez de correr el merge de 67 GB"
),
)
return parser.parse_args()
def main():
args = parse_args()
print(f"[INFO] MODEL_PATH={MODEL_PATH}")
print(f"[INFO] ADAPTER_PATH={ADAPTER_PATH}")
print(f"[INFO] OUTPUT_PATH={OUTPUT_PATH}")
if args.dry_run:
dry_run()
return
deltas = load_lora_deltas()
t0 = time.time()
nontarget_sample = merge_shards(deltas)
+262 -9
View File
@@ -13,7 +13,7 @@ una muestra de data/train.jsonl (el propio dataset de fine-tuning) en vez del
corpus generico ultrachat_200k, aplicando el chat template de PRODUCCION (el que
ya trae MODEL_PATH, no el de masking de training).
Soporta ademas NUM_ULTRACHAT_SAMPLES (default 0) para mezclar N muestras de
Soporta ademas NUM_ULTRACHAT_SAMPLES (default 256) para mezclar N muestras de
HuggingFaceH4/ultrachat_200k (split train_sft, el mismo corpus/split que uso
RedHatAI) con (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES) muestras de
TRAIN_DATA_PATH, concatenadas y mezcladas (shuffle, mismo seed=42) antes de
@@ -62,7 +62,16 @@ confirmado empiricamente que hasta una corrida SIN ultrachat (NUM_CALIBRATION_SA
mezcladas, en el mismo punto exacto del setup interno de oneshot()
(disable_lm_head onload). Separar los dos procesos evita que la maquinaria de
`datasets`/pyarrow/red conviva en el mismo proceso que el modelo cargado.
Al generar el cache se escribe adentro un sidecar provenance.json (ruta/mtime/sha256
de TRAIN_DATA_PATH, los tres numeros de la receta y la distribucion por bucket), y al
cargarlo se compara contra el env de la corrida, ABORTANDO si algo difiere. Es el
riesgo #1 de la fase convertido en asercion: el chequeo anterior (solo el conteo de
filas) no distinguia el cache de Fase 5 -- que tiene exactamente 512 filas, igual que
la receta v2 -- de uno recien generado. Un cache SIN provenance.json tampoco pasa:
ausente no es OK, es "no se pudo verificar de donde viene", y aborta igual.
"""
import hashlib
import json
import os
import random
@@ -96,18 +105,32 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
MODEL_PATH = Path(os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16"))
OUTPUT_PATH = Path(os.environ.get("OUTPUT_PATH", "/workspace/ft-models/Qwen3.6-35B-A3B-mcp-NVFP4"))
TRAIN_DATA_PATH = Path(os.environ.get("TRAIN_DATA_PATH", str(REPO_ROOT / "data" / "train.jsonl")))
NUM_CALIBRATION_SAMPLES = int(os.environ.get("NUM_CALIBRATION_SAMPLES", "256"))
# RECETA DE PRODUCCION -- los defaults de abajo (512 total = 256 de TRAIN_DATA_PATH
# + 256 de ultrachat, MAX_SEQUENCE_LENGTH=2048) son los que produjeron el checkpoint
# NVFP4 que HOY esta en produccion. La prueba esta en el log de esa corrida en spark:
#
# /home/aleleba/ft-models/quantize_nvfp4_v6_mixed_2048.log
#
# Los defaults ORIGINALES de este script eran 256 / 0 / 8192, que fueron el PRIMER
# INTENTO y REGRESARON en calidad (puertas 2-3). Se cambiaron a los de produccion
# justamente para que una corrida pelada no vuelva a pisar esa trampa. Cualquier
# cambio aca requiere volver a correr las puertas 2/3/4 contra el resultado.
NUM_CALIBRATION_SAMPLES = int(os.environ.get("NUM_CALIBRATION_SAMPLES", "512"))
# Muestras adicionales de un corpus generico y amplio (mismo dataset/split que uso
# RedHatAI en su receta de referencia), mezcladas con las de TRAIN_DATA_PATH.
# Hipotesis a probar: la regresion de calidad no es por CANTIDAD de muestras sino
# por DIVERSIDAD -- calibrar solo con conversaciones angostas de los 5 MCPs/skills
# del proyecto podria dejar a los 256 expertos MoE con una vision demasiado
# estrecha. NUM_CALIBRATION_SAMPLES sigue siendo el TOTAL; la porcion de
# TRAIN_DATA_PATH se reduce a (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES).
NUM_ULTRACHAT_SAMPLES = int(os.environ.get("NUM_ULTRACHAT_SAMPLES", "0"))
# Hipotesis probada y CONFIRMADA en quantize_nvfp4_v6_mixed_2048.log: la regresion
# de calidad no era por CANTIDAD de muestras sino por DIVERSIDAD -- calibrar solo con
# conversaciones angostas de los 5 MCPs/skills del proyecto dejaba a los 256 expertos
# MoE con una vision demasiado estrecha. NUM_CALIBRATION_SAMPLES sigue siendo el
# TOTAL; la porcion de TRAIN_DATA_PATH se reduce a
# (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES), o sea 512 - 256 = 256.
NUM_ULTRACHAT_SAMPLES = int(os.environ.get("NUM_ULTRACHAT_SAMPLES", "256"))
ULTRACHAT_DATASET = "HuggingFaceH4/ultrachat_200k"
ULTRACHAT_SPLIT = "train_sft"
MAX_SEQUENCE_LENGTH = int(os.environ.get("MAX_SEQUENCE_LENGTH", "8192"))
# 2048 y no 8192: ver quantize_nvfp4_v6_mixed_2048.log. Truncar mas corto permite
# entrar 512 muestras en el presupuesto de memoria del pool unificado del GB10, y la
# calibracion se beneficia mas de mas muestras diversas que de secuencias largas.
MAX_SEQUENCE_LENGTH = int(os.environ.get("MAX_SEQUENCE_LENGTH", "2048"))
CALIBRATION_SEED = 42
# Preparar la muestra de calibracion (que puede requerir descargar/streamear
# ultrachat_200k via `datasets`/pyarrow/red) y cuantizar (que carga el modelo de
@@ -119,10 +142,176 @@ CALIBRATION_SEED = 42
# procesos: --prepare-calibration construye y guarda el dataset ya tokenizado SIN
# cargar el modelo; la cuantizacion normal lo carga desde disco (sin volver a
# tocar `datasets`/red) antes de cargar el modelo.
#
# #############################################################################
# RIESGO #1 DE LA FASE 6 -- CACHE DE CALIBRACION DE OTRA FASE:
#
# El cache es un Dataset ya tokenizado, sin nada adentro que diga de que
# TRAIN_DATA_PATH, de que receta ni de que fase salio; y el conteo de filas puede
# coincidir por casualidad (el cache de Fase 5 tiene exactamente 512 filas, igual
# que la receta de produccion v2), asi que el viejo chequeo de `len(dataset) !=
# NUM_CALIBRATION_SAMPLES` dejaba pasar el reuso sin un solo warning: se calibraria
# con CERO datos de diseno, lavando justo la capacidad nueva, y todas las
# verificaciones internas pasarian igual.
#
# El cache de Fase 5 esta EN DISCO AHORA en la ruta por default:
# /home/aleleba/ft-models/nvfp4_calibration_cache
#
# Por eso el cache ya NO se carga a ciegas: al GENERARLO se escribe adentro un
# sidecar provenance.json (ruta/mtime/sha256 de TRAIN_DATA_PATH, los tres numeros
# de la receta y la distribucion por bucket) y al CARGARLO se compara contra el env
# actual, abortando si algo difiere. Ausente tampoco es OK: un cache SIN
# provenance.json (por ejemplo el de Fase 5) es "no se pudo verificar de donde
# viene" y aborta igual -- hay que regenerarlo con --prepare-calibration.
#
# REGLA (sigue vigente, la verificacion es la red de seguridad, no el plan): cada
# fase usa su PROPIA ruta de cache, por ejemplo
# CALIBRATION_CACHE_PATH=/workspace/ft-models/nvfp4_calibration_cache_v2
# y verifica en el log la linea "[CALIB]" que este script imprime SIEMPRE con la
# ruta usada, si la cargo o la genero, el conteo de filas y la distribucion por
# bucket.
# #############################################################################
CALIBRATION_CACHE_PATH = Path(
os.environ.get("CALIBRATION_CACHE_PATH", "/workspace/ft-models/nvfp4_calibration_cache")
)
# Nombre del sidecar de procedencia que se escribe DENTRO del directorio del cache.
CALIBRATION_PROVENANCE_FILENAME = "provenance.json"
# Distribucion por bucket de la ultima muestra de TRAIN_DATA_PATH construida en
# ESTE proceso. Solo para reportar; queda vacia cuando la muestra vino del cache
# (un Dataset tokenizado no conserva meta.bucket) Y TAMBIEN cuando la receta no usa
# TRAIN_DATA_PATH en absoluto (NUM_ULTRACHAT_SAMPLES == NUM_CALIBRATION_SAMPLES),
# por eso el reporte se condiciona sobre el ORIGEN real y no sobre este dict vacio.
LAST_TRAIN_BUCKET_COUNTS = {}
def num_train_samples():
"""Cuantas muestras salen de TRAIN_DATA_PATH con la receta actual (el resto es
ultrachat). Cero significa que TRAIN_DATA_PATH no se toca en esta corrida."""
return NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES
def train_data_fingerprint():
"""Huella de TRAIN_DATA_PATH: ruta, mtime y sha256 del contenido. El sha256 es lo
que realmente identifica el dataset (el mtime cambia con un `touch` o una copia)."""
digest = hashlib.sha256()
with open(TRAIN_DATA_PATH, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return {
"path": str(TRAIN_DATA_PATH),
"mtime": TRAIN_DATA_PATH.stat().st_mtime,
"sha256": digest.hexdigest(),
}
def current_calibration_provenance(num_rows):
"""Procedencia de la muestra construida en ESTE proceso, con la receta en uso."""
num_train = num_train_samples()
return {
"train_data": train_data_fingerprint() if num_train > 0 else None,
"recipe": {
"NUM_CALIBRATION_SAMPLES": NUM_CALIBRATION_SAMPLES,
"NUM_ULTRACHAT_SAMPLES": NUM_ULTRACHAT_SAMPLES,
"MAX_SEQUENCE_LENGTH": MAX_SEQUENCE_LENGTH,
},
"num_train_samples": num_train,
"calibration_seed": CALIBRATION_SEED,
"ultrachat_dataset": ULTRACHAT_DATASET,
"ultrachat_split": ULTRACHAT_SPLIT,
"bucket_counts": dict(LAST_TRAIN_BUCKET_COUNTS),
"num_rows": num_rows,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
}
def write_calibration_provenance(dataset):
"""Escribe el sidecar de procedencia dentro del directorio del cache."""
provenance = current_calibration_provenance(len(dataset))
path = CALIBRATION_CACHE_PATH / CALIBRATION_PROVENANCE_FILENAME
path.write_text(json.dumps(provenance, indent=2, sort_keys=True), encoding="utf-8")
print(f"[CALIB] provenance.json escrito en {path}")
return provenance
def verify_calibration_provenance(dataset):
"""Compara el sidecar del cache contra el env/receta de ESTA corrida y aborta si
difieren. Convierte el riesgo #1 (hoy mitigado solo por un comentario) en una
asercion: el unico chequeo anterior era len(dataset) != NUM_CALIBRATION_SAMPLES,
y el cache de Fase 5 tiene exactamente 512 filas igual que la receta v2, o sea
que reusarlo pasaba en verde.
Ausente no es OK: un cache sin provenance.json es 'no se pudo verificar de que
fase viene', y eso aborta -- no se degrada a warning."""
path = CALIBRATION_CACHE_PATH / CALIBRATION_PROVENANCE_FILENAME
if not path.exists():
raise AssertionError(
f"el cache de calibracion {CALIBRATION_CACHE_PATH} no tiene "
f"{CALIBRATION_PROVENANCE_FILENAME}: no se puede verificar con que datos ni con que "
"receta fue construido (es un cache viejo, anterior a este chequeo -- probablemente "
"el de Fase 5). Regenerarlo con --prepare-calibration en una ruta propia de esta fase"
)
provenance = json.loads(path.read_text(encoding="utf-8"))
actual = current_calibration_provenance(len(dataset))
diffs = []
for clave, esperado in actual["recipe"].items():
del_cache = provenance.get("recipe", {}).get(clave, "<AUSENTE>")
if del_cache != esperado:
diffs.append(f"receta.{clave}: cache={del_cache!r} vs corrida actual={esperado!r}")
if provenance.get("num_rows", "<AUSENTE>") != len(dataset):
diffs.append(
f"num_rows: provenance dice {provenance.get('num_rows', '<AUSENTE>')!r} pero el "
f"Dataset en disco tiene {len(dataset)} filas (cache corrupto o pisado)"
)
if len(dataset) != NUM_CALIBRATION_SAMPLES:
diffs.append(
f"filas del cache={len(dataset)} vs NUM_CALIBRATION_SAMPLES={NUM_CALIBRATION_SAMPLES}"
)
if provenance.get("calibration_seed", "<AUSENTE>") != CALIBRATION_SEED:
diffs.append(
f"calibration_seed: cache={provenance.get('calibration_seed', '<AUSENTE>')!r} vs "
f"corrida actual={CALIBRATION_SEED!r}"
)
cache_train = provenance.get("train_data", "<AUSENTE>")
actual_train = actual["train_data"]
if cache_train == "<AUSENTE>":
diffs.append("train_data ausente del provenance.json -- no se puede verificar el dataset de calibracion")
elif (cache_train is None) != (actual_train is None):
diffs.append(
f"uso de TRAIN_DATA_PATH: cache={'ninguno (100% ultrachat)' if cache_train is None else cache_train.get('path')} "
f"vs corrida actual={'ninguno (100% ultrachat)' if actual_train is None else actual_train['path']}"
)
elif actual_train is not None:
if cache_train.get("path") != actual_train["path"]:
diffs.append(
f"TRAIN_DATA_PATH: cache={cache_train.get('path')!r} vs corrida actual={actual_train['path']!r}"
)
if cache_train.get("sha256") != actual_train["sha256"]:
diffs.append(
f"sha256 de {actual_train['path']}: cache={cache_train.get('sha256')} vs "
f"archivo actual={actual_train['sha256']} -- el cache se construyo con OTRO contenido"
)
elif cache_train.get("mtime") != actual_train["mtime"]:
# Mismo contenido, otro mtime: una copia o un touch. No invalida el cache.
print(
f"[CALIB] [WARN] mtime de {actual_train['path']} cambio "
f"({cache_train.get('mtime')} -> {actual_train['mtime']}) pero el sha256 coincide "
"-- mismo contenido, no invalida el cache"
)
if diffs:
raise AssertionError(
f"el cache de calibracion en {CALIBRATION_CACHE_PATH} NO corresponde a esta corrida:\n - "
+ "\n - ".join(diffs)
+ f"\nRegenerarlo con --prepare-calibration y CALIBRATION_CACHE_PATH propio de esta fase. "
f"(provenance generado el {provenance.get('generated_at', '?')})"
)
print("[CALIB] provenance.json del cache verificado contra la receta actual: coincide")
return provenance
# Receta identica a la de RedHatAI/Qwen3.6-35B-A3B-NVFP4 (recipe.yaml leido por SSH,
# citado integro en PLAN.md). Las capas Gated DeltaNet (linear_attn) quedan en BF16
# a proposito -- el LoRA se entreno ahi, pero al no cuantizarse no se agrega perdida
@@ -160,6 +349,8 @@ def load_train_examples(n):
bucket_counts = Counter(ex.get("meta", {}).get("bucket", "?") for ex in sampled)
print(f"[INFO] distribucion de buckets (train.jsonl): {dict(bucket_counts)}")
LAST_TRAIN_BUCKET_COUNTS.clear()
LAST_TRAIN_BUCKET_COUNTS.update(bucket_counts)
return sampled
@@ -230,6 +421,53 @@ def load_calibration_dataset(tokenizer):
return Dataset.from_dict({"input_ids": input_ids_list, "attention_mask": attention_mask_list})
def report_calibration_source(dataset, source, from_cache=False, provenance=None):
"""Reporte [CALIB] -- se imprime SIEMPRE, en los dos caminos (cache o construida
en el proceso): la ruta queda escrita en el log de la corrida, junto al conteo de
filas y a la distribucion por bucket, para poder auditarlo despues. La defensa
dura contra el riesgo #1 es verify_calibration_provenance(); esto es el rastro.
El origen se pasa EXPLICITO (from_cache) y no se deduce de que
LAST_TRAIN_BUCKET_COUNTS este vacio: ese dict tambien queda vacio cuando la
receta no usa TRAIN_DATA_PATH (NUM_ULTRACHAT_SAMPLES == NUM_CALIBRATION_SAMPLES),
y entonces el mensaje mentia diciendo que la muestra habia venido del cache."""
num_train = num_train_samples()
print("[CALIB] ===== muestra de calibracion =====")
print(f"[CALIB] CALIBRATION_CACHE_PATH = {CALIBRATION_CACHE_PATH}")
print(f"[CALIB] origen = {source}")
print(f"[CALIB] filas = {len(dataset)}")
print(
f"[CALIB] receta (env) = NUM_CALIBRATION_SAMPLES={NUM_CALIBRATION_SAMPLES} "
f"NUM_ULTRACHAT_SAMPLES={NUM_ULTRACHAT_SAMPLES} MAX_SEQUENCE_LENGTH={MAX_SEQUENCE_LENGTH}"
)
print(f"[CALIB] TRAIN_DATA_PATH = {TRAIN_DATA_PATH} ({num_train} muestras de aca)")
print(f"[CALIB] muestras de {ULTRACHAT_DATASET} = {NUM_ULTRACHAT_SAMPLES}")
if from_cache:
buckets_cache = (provenance or {}).get("bucket_counts")
if buckets_cache:
print(f"[CALIB] buckets (del provenance del cache) = {buckets_cache}")
else:
print(
"[CALIB] buckets = no disponibles -- la muestra vino del cache ya "
"tokenizado (que no conserva meta.bucket) y su provenance.json no los registro"
)
if provenance:
print(f"[CALIB] cache generado el = {provenance.get('generated_at', '?')}")
elif num_train == 0:
print(
"[CALIB] buckets = no aplica -- esta receta no usa TRAIN_DATA_PATH "
"(NUM_ULTRACHAT_SAMPLES == NUM_CALIBRATION_SAMPLES): la muestra es 100% ultrachat"
)
else:
print(f"[CALIB] buckets de {TRAIN_DATA_PATH.name} = {dict(LAST_TRAIN_BUCKET_COUNTS)}")
if len(dataset) != NUM_CALIBRATION_SAMPLES:
print(
f"[CALIB] [WARN] la muestra tiene {len(dataset)} filas pero NUM_CALIBRATION_SAMPLES="
f"{NUM_CALIBRATION_SAMPLES} -- NO fue generada con esta receta"
)
print("[CALIB] ====================================")
class CalibrationDataCollator:
"""Padding simple a la derecha -- sin labels, oneshot solo necesita forward pass."""
@@ -426,6 +664,10 @@ def prepare_calibration():
CALIBRATION_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
calibration_dataset.save_to_disk(str(CALIBRATION_CACHE_PATH))
print(f"[INFO] muestra de calibracion guardada en {CALIBRATION_CACHE_PATH} ({len(calibration_dataset)} ejemplos)")
# Sidecar de procedencia: sin esto el cache es indistinguible del de cualquier
# otra fase (mismo formato, y hasta el mismo conteo de filas).
write_calibration_provenance(calibration_dataset)
report_calibration_source(calibration_dataset, "GENERADA en este proceso y guardada en el cache")
def main():
@@ -450,6 +692,14 @@ def main():
print(f"[INFO] cargando muestra de calibracion YA PREPARADA desde {CALIBRATION_CACHE_PATH}")
calibration_dataset = Dataset.load_from_disk(str(CALIBRATION_CACHE_PATH))
print(f"[INFO] {len(calibration_dataset)} ejemplos cargados desde el cache (sin tocar datasets/red)")
# Aborta si el cache no corresponde a esta corrida (riesgo #1).
provenance = verify_calibration_provenance(calibration_dataset)
report_calibration_source(
calibration_dataset,
"CARGADA DEL CACHE EN DISCO (provenance.json verificado contra la receta actual)",
from_cache=True,
provenance=provenance,
)
else:
print(f"[INFO] TRAIN_DATA_PATH={TRAIN_DATA_PATH}")
print(f"[INFO] NUM_CALIBRATION_SAMPLES={NUM_CALIBRATION_SAMPLES} MAX_SEQUENCE_LENGTH={MAX_SEQUENCE_LENGTH}")
@@ -459,6 +709,9 @@ def main():
"calibracion en este mismo proceso (usar --prepare-calibration antes evita esto)"
)
calibration_dataset = load_calibration_dataset(tokenizer)
report_calibration_source(
calibration_dataset, "GENERADA en este mismo proceso (no habia cache en disco)"
)
import gc
+168 -13
View File
@@ -10,18 +10,40 @@ contenedor qwen-lora-train en spark:
Carga el checkpoint mergeado con AutoModelForCausalLM (para detectar bugs de
merge que un eval sobre el adapter puro no veria), le pisa en memoria el
chat_template con data/chat_template_train.jinja (igual que en training, para
poder generar assistant_masks), recorre data/eval.jsonl agrupado por
meta.bucket, y reporta loss promedio global y por bucket (aislando
bucket=="replay"), comparado contra eval_loss=0.275 de Fase 3.
poder generar assistant_masks), recorre EVAL_FILE (default: data/eval.jsonl)
agrupado por meta.bucket, y reporta loss promedio global y por bucket (aislando
bucket=="replay"), comparado contra BASELINE_EVAL_LOSS (default: el
eval_loss=0.2750 de Fase 3). Rutas y baselines son configurables por env var --
ver el bloque de constantes.
Las puertas 2-4 (tool-calls, adherencia, E2E) viven en scripts separados
(scripts/31_gate2_toolcalls.py, scripts/32_gate3_adherencia.py,
scripts/33_gate4_e2e.py) porque necesitan el contenedor de eval sirviendo el
checkpoint mergeado via HTTP, no solo lectura offline.
Fase 6: EVAL_FILE, BASELINE_EVAL_LOSS y BASELINE_BUCKET_LOSSES son env vars. Los
defaults son el eval.jsonl CONGELADO y el eval_loss=0.2750, ambos de Fase 3 -- de
modo que una corrida pelada reproduce exactamente la medicion de la puerta 1 y la
unica variable entre baseline y candidato sea el propio LoRA #2. El reporte imprime
la perdida ponderada global y el desglose por bucket con su delta contra el baseline,
y un veredicto PASS/FAIL explicito por cada uno de los dos umbrales:
- MAX_GLOBAL_WEIGHTED_LOSS (default 0.35): perdida ponderada global.
- MAX_BUCKET_REGRESSION (default 0.10): ningun bucket NO-penpot puede estar peor
que SU PROPIO baseline por mas de ese margen.
PRINCIPIO RECTOR de todos los chequeos de este script: ausente no es OK; ausente es
"no se pudo verificar", y eso tiene que fallar o reportarse como SKIP explicito,
nunca colarse como verde. En concreto: un bucket sin baseline propio NO se compara
contra el numero global (eso produce falsos PASS y falsos FAIL por igual) -- se marca
SKIP y el veredicto reporta cuantos buckets quedaron sin verificar.
El script sale con codigo 1 si la puerta no pasa: el runbook la encadena con la
cuantizacion, y una puerta que no puede fallar no es una puerta.
"""
import argparse
import json
import os
import sys
import time
from collections import defaultdict
from pathlib import Path
@@ -32,8 +54,63 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
REPO_ROOT = Path(__file__).resolve().parent.parent
OUTPUT_PATH = os.environ.get("OUTPUT_PATH", "/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16")
TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja"
EVAL_FILE = REPO_ROOT / "data" / "eval.jsonl"
FASE3_EVAL_LOSS = 0.275
# Por env para poder apuntar a data/eval_lora2.jsonl sin tocar el script, pero el
# default sigue siendo el eval.jsonl CONGELADO de Fase 3: es la procedencia exacta
# del modelo en produccion y el unico archivo contra el que 0.2750 significa algo.
EVAL_FILE = Path(os.environ.get("EVAL_FILE", str(REPO_ROOT / "data" / "eval.jsonl")))
# eval_loss de Fase 3 (adapter puro, transformers.Trainer, ponderado por token).
BASELINE_EVAL_LOSS = float(os.environ.get("BASELINE_EVAL_LOSS", "0.2750"))
# Baselines por bucket (JSON: {"bucket": loss, ...}). El Trainer solo reporta el
# numero global, asi que los por-bucket hay que pasarlos a mano desde la corrida
# anterior de esta misma puerta.
#
# Un bucket SIN entrada aca NO se compara contra BASELINE_EVAL_LOSS: el numero
# global no es el baseline de ningun bucket en particular, y usarlo como tal produce
# tanto falsos PASS (un bucket que valia 0.12 y ahora vale 0.33 queda dentro del
# margen contra 0.2750) como falsos FAIL (un bucket que siempre valio 0.42 y sigue
# igual "regresiona" +0.145). Ausente no es OK; ausente es "no se pudo verificar":
# la fila se marca SKIP y el veredicto reporta cuantos buckets quedaron sin cubrir.
def _parse_baseline_bucket_losses():
"""Parsea BASELINE_BUCKET_LOSSES. Se llama en tiempo de import, asi que un JSON
mal formado tiene que salir con un mensaje claro (y no con un traceback crudo
hasta corriendo --help)."""
raw = os.environ.get("BASELINE_BUCKET_LOSSES", "{}")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(
f"[ERROR] BASELINE_BUCKET_LOSSES no es JSON valido ({exc}). "
f'Valor recibido: {raw!r}. Formato esperado: {{"bucket": 0.1234, ...}}'
)
if not isinstance(parsed, dict):
raise SystemExit(
f"[ERROR] BASELINE_BUCKET_LOSSES debe ser un objeto JSON, se recibio {type(parsed).__name__}: {raw!r}"
)
out = {}
for bucket, value in parsed.items():
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise SystemExit(
f"[ERROR] BASELINE_BUCKET_LOSSES[{bucket!r}] = {value!r} no es un numero"
)
out[bucket] = float(value)
return out
BASELINE_BUCKET_LOSSES = _parse_baseline_bucket_losses()
# Umbrales de la puerta de olvido (Fase 6).
MAX_GLOBAL_WEIGHTED_LOSS = float(os.environ.get("MAX_GLOBAL_WEIGHTED_LOSS", "0.35"))
MAX_BUCKET_REGRESSION = float(os.environ.get("MAX_BUCKET_REGRESSION", "0.10"))
# Bucket exento del umbral de regresion por bucket: es donde el LoRA #2 debe moverse.
# OJO con lo que esta exencion NO significa: en data/eval.jsonl las 11 filas de este
# bucket son PREEXISTENTES del LoRA #1 (tool-calling del MCP de Penpot, ensenado en
# Fase 3), no capacidad nueva. Exentarlas cumple el plan al pie de la letra, pero deja
# a la puerta 1 sin cobertura de regresion sobre la capacidad Penpot VIEJA -- que es
# justo la que el LoRA #2 mas va a pisar. Si se quiere esa cobertura, hay que dejar el
# bucket bajo umbral (PENPOT_BUCKET="") y separar la capacidad nueva en otro archivo
# de eval.
PENPOT_BUCKET = os.environ.get("PENPOT_BUCKET", "penpot")
def parse_args():
@@ -127,40 +204,118 @@ def run_gate1():
global_avg_weighted = weighted_avg(all_pairs)
print("\n=== Puerta 1 -- eval-loss offline por bucket (checkpoint mergeado) ===")
print(f"[INFO] EVAL_FILE={EVAL_FILE}")
print(f"[INFO] BASELINE_EVAL_LOSS={BASELINE_EVAL_LOSS:.4f}")
print(f"[INFO] tiempo de eval: {eval_time:.1f}s, memoria pico: {peak_mem_gb:.2f} GB")
# Toda clave de BASELINE_BUCKET_LOSSES tiene que existir entre los buckets
# encontrados: un typo ("otros_mcp" por "otros_mcps") no matchea nada, no dice
# nada, y deja ese bucket sin verificar mientras el operador cree que lo cubrio.
buckets_encontrados = set(losses_by_bucket)
baselines_sobrantes = sorted(set(BASELINE_BUCKET_LOSSES) - buckets_encontrados)
if baselines_sobrantes:
print(
f"[ERROR] BASELINE_BUCKET_LOSSES tiene claves que no existen en {EVAL_FILE}: "
f"{baselines_sobrantes} (buckets encontrados: {sorted(buckets_encontrados)}). "
"Probablemente un typo -- ese baseline no se estaria aplicando a nada."
)
raise SystemExit(1)
# Desglose por bucket con el delta contra SU PROPIO baseline. El delta es lo que
# decide la puerta de olvido: un promedio global sano puede esconder un bucket
# no-penpot que se degrado, compensado por la mejora del bucket penpot.
print(
f"\n {'bucket':22s} {'n':>4s} {'tokens':>7s} {'simple':>8s} {'ponderado':>10s} "
f"{'baseline':>9s} {'delta':>8s}"
)
bucket_regressions = []
buckets_sin_baseline = []
for bucket in sorted(losses_by_bucket):
pairs = losses_by_bucket[bucket]
n_tokens_total = sum(n for _, n in pairs)
w = weighted_avg(pairs)
# Nunca se inventa un baseline: si no hay uno propio para este bucket, el
# umbral simplemente NO se evalua y la fila se marca SKIP.
baseline = BASELINE_BUCKET_LOSSES.get(bucket)
es_penpot = bucket == PENPOT_BUCKET
if es_penpot:
nota = " (exento: tool-calling Penpot PREEXISTENTE del LoRA #1, sin cobertura de regresion aca)"
elif baseline is None:
nota = " SKIP (sin baseline propio -- no se pudo verificar)"
buckets_sin_baseline.append(bucket)
else:
nota = ""
baseline_txt = f"{baseline:9.4f}" if baseline is not None else f"{'n/d':>9s}"
delta_txt = f"{w - baseline:+8.4f}" if baseline is not None else f"{'--':>8s}"
print(
f" bucket={bucket:20s} n={len(pairs):4d} tokens={n_tokens_total:6d} "
f"loss_avg_simple={simple_avg(pairs):.4f} loss_avg_weighted={weighted_avg(pairs):.4f}"
f" {bucket:22s} {len(pairs):4d} {n_tokens_total:7d} {simple_avg(pairs):8.4f} "
f"{w:10.4f} {baseline_txt} {delta_txt}{nota}"
)
if not es_penpot and baseline is not None and (w - baseline) > MAX_BUCKET_REGRESSION:
bucket_regressions.append((bucket, w, baseline, w - baseline))
replay_pairs = losses_by_bucket.get("replay")
if replay_pairs:
print(
f" bucket=replay (aislado) n={len(replay_pairs):4d} "
f"\n bucket=replay (aislado) n={len(replay_pairs):4d} "
f"loss_avg_simple={simple_avg(replay_pairs):.4f} loss_avg_weighted={weighted_avg(replay_pairs):.4f}"
)
print(f"\n loss_avg GLOBAL simple (por ejemplo) = {global_avg_simple:.4f}")
print(f" loss_avg GLOBAL ponderado (por token) = {global_avg_weighted:.4f}")
print(f" eval_loss Fase 3 (adapter puro, Trainer, ponderado por token) = {FASE3_EVAL_LOSS:.4f}")
diff = abs(global_avg_weighted - FASE3_EVAL_LOSS)
print(f" diferencia absoluta (ponderado vs Fase 3) = {diff:.4f}")
print(f" baseline (ponderado por token) = {BASELINE_EVAL_LOSS:.4f}")
diff = abs(global_avg_weighted - BASELINE_EVAL_LOSS)
print(f" diferencia absoluta (ponderado vs baseline) = {diff:.4f}")
if diff > 0.05:
print(
" [WARN] diferencia > 0.05 -- senal posible de bug real en el merge, "
"revisar antes de continuar a la puerta 2"
)
else:
print(" [OK] loss del checkpoint mergeado consistente con Fase 3 -- merge probablemente correcto")
print(" [OK] loss del checkpoint mergeado consistente con el baseline -- merge probablemente correcto")
# Veredicto explicito, uno por umbral. Son dos condiciones independientes: la
# puerta pasa solo si las dos pasan.
print("\n=== Veredicto de la puerta de olvido ===")
global_pass = global_avg_weighted <= MAX_GLOBAL_WEIGHTED_LOSS
print(
f" [{'PASS' if global_pass else 'FAIL'}] perdida ponderada global "
f"{global_avg_weighted:.4f} <= {MAX_GLOBAL_WEIGHTED_LOSS:.4f} (MAX_GLOBAL_WEIGHTED_LOSS)"
)
buckets_pass = not bucket_regressions
# Cuantos buckets pasaron REALMENTE por el umbral: ni los exentos ni los que
# quedaron en SKIP cuentan como verificados.
n_verificados = len(losses_by_bucket) - len(buckets_sin_baseline) - (1 if PENPOT_BUCKET in losses_by_bucket else 0)
print(
f" [{'PASS' if buckets_pass else 'FAIL'}] ningun bucket no-{PENPOT_BUCKET} peor que su "
f"baseline por mas de {MAX_BUCKET_REGRESSION:.4f} (MAX_BUCKET_REGRESSION) "
f"-- umbral evaluado sobre {n_verificados}/{len(losses_by_bucket)} buckets"
)
for bucket, w, baseline, delta in bucket_regressions:
print(f" bucket={bucket}: {w:.4f} vs baseline {baseline:.4f} (delta {delta:+.4f})")
if buckets_sin_baseline:
print(
f" [SKIP] {len(buckets_sin_baseline)} bucket(s) SIN VERIFICAR por falta de baseline "
f"propio en BASELINE_BUCKET_LOSSES: {buckets_sin_baseline}"
)
print(
" Ausente no es OK: es 'no se pudo verificar'. Estos buckets NO pasaron por "
"el umbral de regresion -- pasarles su baseline medido para que la puerta los cubra."
)
todo_pasa = global_pass and buckets_pass
sufijo = f" (con {len(buckets_sin_baseline)} bucket(s) SIN VERIFICAR)" if buckets_sin_baseline else ""
print(f"\n VEREDICTO PUERTA 1: {'PASS' if todo_pasa else 'FAIL'}{sufijo}")
return todo_pasa
def main():
args = parse_args()
if args.gate == 1:
run_gate1()
# Codigo de salida != 0 cuando la puerta no pasa: el runbook la encadena con
# la cuantizacion, y una puerta que no puede fallar automaticamente no es una
# puerta.
sys.exit(0 if run_gate1() else 1)
if __name__ == "__main__":
+55 -3
View File
@@ -75,6 +75,11 @@ DISJOINT_AGAINST = [
]
SHINGLE_N = 6
# Reintento de transporte contra el MCP de Penpot. El servidor corta la conexion a mitad de
# llamada de forma intermitente; ya se habia visto en la puerta 4 de la Fase 4.
MCP_MAX_RETRIES = int(os.environ.get("PENPOT_MCP_RETRIES", "4"))
MCP_RETRY_BACKOFF = float(os.environ.get("PENPOT_MCP_RETRY_BACKOFF", "3"))
MAX_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14"))
MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096"))
HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600"))
@@ -276,11 +281,36 @@ class PenpotMCP:
raise Gate5Error(f"el MCP de Penpot no devolvio respuesta para el metodo {payload.get('method')}")
def _request(self, method, params=None):
"""Envia un request JSON-RPC, reintentando los fallos de TRANSPORTE.
El MCP de Penpot corta la conexion a mitad de llamada de forma intermitente
("transport dropped mid-call"); ya se habia visto en la puerta 4 de la Fase 4. Sin
reintento, un corte transitorio en el prompt 6 de 8 tira abajo una corrida entera de la
puerta, y peor: se diagnostica como un fallo del modelo cuando es de la red.
Se reintenta SOLO `Gate5Error` de transporte/protocolo. `PluginNotConnected` no se
reintenta -- ese es un estado real del mundo (el usuario no tiene el plugin abierto) y
reintentarlo solo demora el mensaje que hay que dar.
"""
ultimo = None
for intento in range(1, MCP_MAX_RETRIES + 1):
self._next_id += 1
payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
if params is not None:
payload["params"] = params
try:
return self._post(payload)
except PluginNotConnected:
raise
except Gate5Error as e:
ultimo = e
if intento == MCP_MAX_RETRIES:
break
espera = MCP_RETRY_BACKOFF * intento
print(f"[MCP] fallo transitorio en '{method}' (intento {intento}/"
f"{MCP_MAX_RETRIES}): {e}. Reintento en {espera:.0f}s")
time.sleep(espera)
raise Gate5Error(f"el MCP de Penpot fallo {MCP_MAX_RETRIES} veces en '{method}': {ultimo}")
def _notify(self, method, params=None):
payload = {"jsonrpc": "2.0", "method": method}
@@ -334,12 +364,34 @@ class PenpotMCP:
return texto, es_error
def execute_json(self, code, que):
"""execute_code cuyo resultado la puerta necesita parsear como JSON."""
"""execute_code cuyo resultado la puerta necesita parsear como JSON.
El servidor MCP no devuelve el `return` del payload pelado: lo envuelve en
`{"result": <lo-que-retornaste>, "log": "<lo-que-escribiste-por-console>"}`. La puerta
necesita el interior, asi que se desenvuelve ACA, en un solo lugar.
Ojo con no desenvolver de mas: si un payload de la puerta retornara a proposito un
objeto con una clave `result` propia, desenvolver a ciegas lo destruiria. Por eso se
exige la forma exacta del envoltorio (`result` presente y ninguna clave fuera de
{result, log}).
`execute_code` (el metodo de abajo) NO desenvuelve: lo que ve el modelo como tool result
tiene que ser byte a byte lo que le llegaria en produccion, envoltorio incluido.
"""
texto, es_error = self.execute_code(code)
try:
return json.loads(texto)
parsed = json.loads(texto)
except json.JSONDecodeError:
raise Gate5Error(f"{que}: el MCP no devolvio JSON ({'error' if es_error else 'texto'}): {texto[:300]}")
raise Gate5Error(
f"{que}: el MCP no devolvio JSON ({'error' if es_error else 'texto'}): {texto[:300]}"
)
if (isinstance(parsed, dict) and "result" in parsed
and set(parsed).issubset({"result", "log"})):
log = (parsed.get("log") or "").strip()
if log:
print(f"[MCP] console del payload de la puerta ({que}): {log[:300]}")
return parsed["result"]
return parsed
# ------------------------------------------------------------------------------------------