Phase 6: train a second LoRA for real Penpot UI design capability #5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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):
|
||||
self._next_id += 1
|
||||
payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return self._post(payload)
|
||||
"""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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user