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 retries: 5
start_period: 600s 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 # Fase 5 -- diagnostico de aislamiento: identico a vllm-eval-nvfp4 pero SIN
# --speculative-config, para determinar si la regresion de calidad observada # --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 # 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 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; byte-a-byte identico al base; todo tensor LoRA-target con delta no-cero;
sin NaN/Inf. 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 gc
import json import json
import os import os
@@ -44,6 +60,48 @@ ADAPTER_PREFIX = "base_model.model."
LORA_A_SUFFIX = ".lora_A.weight" LORA_A_SUFFIX = ".lora_A.weight"
LORA_B_SUFFIX = ".lora_B.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 # 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 # 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 # "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(): def main():
args = parse_args()
print(f"[INFO] MODEL_PATH={MODEL_PATH}") print(f"[INFO] MODEL_PATH={MODEL_PATH}")
print(f"[INFO] ADAPTER_PATH={ADAPTER_PATH}") print(f"[INFO] ADAPTER_PATH={ADAPTER_PATH}")
print(f"[INFO] OUTPUT_PATH={OUTPUT_PATH}") print(f"[INFO] OUTPUT_PATH={OUTPUT_PATH}")
if args.dry_run:
dry_run()
return
deltas = load_lora_deltas() deltas = load_lora_deltas()
t0 = time.time() t0 = time.time()
nontarget_sample = merge_shards(deltas) 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 corpus generico ultrachat_200k, aplicando el chat template de PRODUCCION (el que
ya trae MODEL_PATH, no el de masking de training). 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 HuggingFaceH4/ultrachat_200k (split train_sft, el mismo corpus/split que uso
RedHatAI) con (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES) muestras de RedHatAI) con (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES) muestras de
TRAIN_DATA_PATH, concatenadas y mezcladas (shuffle, mismo seed=42) antes 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() mezcladas, en el mismo punto exacto del setup interno de oneshot()
(disable_lm_head onload). Separar los dos procesos evita que la maquinaria de (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. `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 json
import os import os
import random 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")) 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")) 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"))) 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 # 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. # 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 # Hipotesis probada y CONFIRMADA en quantize_nvfp4_v6_mixed_2048.log: la regresion
# por DIVERSIDAD -- calibrar solo con conversaciones angostas de los 5 MCPs/skills # de calidad no era por CANTIDAD de muestras sino por DIVERSIDAD -- calibrar solo con
# del proyecto podria dejar a los 256 expertos MoE con una vision demasiado # conversaciones angostas de los 5 MCPs/skills del proyecto dejaba a los 256 expertos
# estrecha. NUM_CALIBRATION_SAMPLES sigue siendo el TOTAL; la porcion de # MoE con una vision demasiado estrecha. NUM_CALIBRATION_SAMPLES sigue siendo el
# TRAIN_DATA_PATH se reduce a (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES). # TOTAL; la porcion de TRAIN_DATA_PATH se reduce a
NUM_ULTRACHAT_SAMPLES = int(os.environ.get("NUM_ULTRACHAT_SAMPLES", "0")) # (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_DATASET = "HuggingFaceH4/ultrachat_200k"
ULTRACHAT_SPLIT = "train_sft" 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 CALIBRATION_SEED = 42
# Preparar la muestra de calibracion (que puede requerir descargar/streamear # Preparar la muestra de calibracion (que puede requerir descargar/streamear
# ultrachat_200k via `datasets`/pyarrow/red) y cuantizar (que carga el modelo de # 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 # procesos: --prepare-calibration construye y guarda el dataset ya tokenizado SIN
# cargar el modelo; la cuantizacion normal lo carga desde disco (sin volver a # cargar el modelo; la cuantizacion normal lo carga desde disco (sin volver a
# tocar `datasets`/red) antes de cargar el modelo. # 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( CALIBRATION_CACHE_PATH = Path(
os.environ.get("CALIBRATION_CACHE_PATH", "/workspace/ft-models/nvfp4_calibration_cache") 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, # 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 # 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 # 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) bucket_counts = Counter(ex.get("meta", {}).get("bucket", "?") for ex in sampled)
print(f"[INFO] distribucion de buckets (train.jsonl): {dict(bucket_counts)}") 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 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}) 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: class CalibrationDataCollator:
"""Padding simple a la derecha -- sin labels, oneshot solo necesita forward pass.""" """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_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
calibration_dataset.save_to_disk(str(CALIBRATION_CACHE_PATH)) calibration_dataset.save_to_disk(str(CALIBRATION_CACHE_PATH))
print(f"[INFO] muestra de calibracion guardada en {CALIBRATION_CACHE_PATH} ({len(calibration_dataset)} ejemplos)") 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(): def main():
@@ -450,6 +692,14 @@ def main():
print(f"[INFO] cargando muestra de calibracion YA PREPARADA desde {CALIBRATION_CACHE_PATH}") print(f"[INFO] cargando muestra de calibracion YA PREPARADA desde {CALIBRATION_CACHE_PATH}")
calibration_dataset = Dataset.load_from_disk(str(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)") 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: else:
print(f"[INFO] TRAIN_DATA_PATH={TRAIN_DATA_PATH}") print(f"[INFO] TRAIN_DATA_PATH={TRAIN_DATA_PATH}")
print(f"[INFO] NUM_CALIBRATION_SAMPLES={NUM_CALIBRATION_SAMPLES} MAX_SEQUENCE_LENGTH={MAX_SEQUENCE_LENGTH}") 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)" "calibracion en este mismo proceso (usar --prepare-calibration antes evita esto)"
) )
calibration_dataset = load_calibration_dataset(tokenizer) calibration_dataset = load_calibration_dataset(tokenizer)
report_calibration_source(
calibration_dataset, "GENERADA en este mismo proceso (no habia cache en disco)"
)
import gc 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 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 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 chat_template con data/chat_template_train.jinja (igual que en training, para
poder generar assistant_masks), recorre data/eval.jsonl agrupado por poder generar assistant_masks), recorre EVAL_FILE (default: data/eval.jsonl)
meta.bucket, y reporta loss promedio global y por bucket (aislando agrupado por meta.bucket, y reporta loss promedio global y por bucket (aislando
bucket=="replay"), comparado contra eval_loss=0.275 de Fase 3. 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 Las puertas 2-4 (tool-calls, adherencia, E2E) viven en scripts separados
(scripts/31_gate2_toolcalls.py, scripts/32_gate3_adherencia.py, (scripts/31_gate2_toolcalls.py, scripts/32_gate3_adherencia.py,
scripts/33_gate4_e2e.py) porque necesitan el contenedor de eval sirviendo el scripts/33_gate4_e2e.py) porque necesitan el contenedor de eval sirviendo el
checkpoint mergeado via HTTP, no solo lectura offline. 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 argparse
import json import json
import os import os
import sys
import time import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
@@ -32,8 +54,63 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
OUTPUT_PATH = os.environ.get("OUTPUT_PATH", "/workspace/ft-models/Qwen3.6-35B-A3B-mcp-bf16") 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" TRAIN_CHAT_TEMPLATE_PATH = REPO_ROOT / "data" / "chat_template_train.jinja"
EVAL_FILE = REPO_ROOT / "data" / "eval.jsonl" # Por env para poder apuntar a data/eval_lora2.jsonl sin tocar el script, pero el
FASE3_EVAL_LOSS = 0.275 # 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(): def parse_args():
@@ -127,40 +204,118 @@ def run_gate1():
global_avg_weighted = weighted_avg(all_pairs) global_avg_weighted = weighted_avg(all_pairs)
print("\n=== Puerta 1 -- eval-loss offline por bucket (checkpoint mergeado) ===") 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") 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): for bucket in sorted(losses_by_bucket):
pairs = losses_by_bucket[bucket] pairs = losses_by_bucket[bucket]
n_tokens_total = sum(n for _, n in pairs) 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( print(
f" bucket={bucket:20s} n={len(pairs):4d} tokens={n_tokens_total:6d} " f" {bucket:22s} {len(pairs):4d} {n_tokens_total:7d} {simple_avg(pairs):8.4f} "
f"loss_avg_simple={simple_avg(pairs):.4f} loss_avg_weighted={weighted_avg(pairs):.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") replay_pairs = losses_by_bucket.get("replay")
if replay_pairs: if replay_pairs:
print( 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}" 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"\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" 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}") print(f" baseline (ponderado por token) = {BASELINE_EVAL_LOSS:.4f}")
diff = abs(global_avg_weighted - FASE3_EVAL_LOSS) diff = abs(global_avg_weighted - BASELINE_EVAL_LOSS)
print(f" diferencia absoluta (ponderado vs Fase 3) = {diff:.4f}") print(f" diferencia absoluta (ponderado vs baseline) = {diff:.4f}")
if diff > 0.05: if diff > 0.05:
print( print(
" [WARN] diferencia > 0.05 -- senal posible de bug real en el merge, " " [WARN] diferencia > 0.05 -- senal posible de bug real en el merge, "
"revisar antes de continuar a la puerta 2" "revisar antes de continuar a la puerta 2"
) )
else: 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(): def main():
args = parse_args() args = parse_args()
if args.gate == 1: 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__": if __name__ == "__main__":
+55 -3
View File
@@ -75,6 +75,11 @@ DISJOINT_AGAINST = [
] ]
SHINGLE_N = 6 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_TURNS = int(os.environ.get("GATE5_MAX_TURNS", "14"))
MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096")) MAX_TOKENS = int(os.environ.get("GATE5_MAX_TOKENS", "4096"))
HTTP_TIMEOUT = int(os.environ.get("GATE5_HTTP_TIMEOUT", "600")) 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')}") raise Gate5Error(f"el MCP de Penpot no devolvio respuesta para el metodo {payload.get('method')}")
def _request(self, method, params=None): 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 self._next_id += 1
payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method} payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
if params is not None: if params is not None:
payload["params"] = params payload["params"] = params
try:
return self._post(payload) 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): def _notify(self, method, params=None):
payload = {"jsonrpc": "2.0", "method": method} payload = {"jsonrpc": "2.0", "method": method}
@@ -334,12 +364,34 @@ class PenpotMCP:
return texto, es_error return texto, es_error
def execute_json(self, code, que): 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) texto, es_error = self.execute_code(code)
try: try:
return json.loads(texto) parsed = json.loads(texto)
except json.JSONDecodeError: 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
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------