diff --git a/docker-compose.eval.yml b/docker-compose.eval.yml index 6f092c9..ef24182 100644 --- a/docker-compose.eval.yml +++ b/docker-compose.eval.yml @@ -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 diff --git a/scripts/20_merge_lora.py b/scripts/20_merge_lora.py index ea13056..278fe80 100644 --- a/scripts/20_merge_lora.py +++ b/scripts/20_merge_lora.py @@ -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) diff --git a/scripts/21_quantize_nvfp4.py b/scripts/21_quantize_nvfp4.py index 8556d68..c0c39bd 100644 --- a/scripts/21_quantize_nvfp4.py +++ b/scripts/21_quantize_nvfp4.py @@ -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, "") + if del_cache != esperado: + diffs.append(f"receta.{clave}: cache={del_cache!r} vs corrida actual={esperado!r}") + if provenance.get("num_rows", "") != len(dataset): + diffs.append( + f"num_rows: provenance dice {provenance.get('num_rows', '')!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", "") != CALIBRATION_SEED: + diffs.append( + f"calibration_seed: cache={provenance.get('calibration_seed', '')!r} vs " + f"corrida actual={CALIBRATION_SEED!r}" + ) + + cache_train = provenance.get("train_data", "") + actual_train = actual["train_data"] + if cache_train == "": + 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 diff --git a/scripts/30_eval_suite.py b/scripts/30_eval_suite.py index dfded3f..d2d3fc5 100644 --- a/scripts/30_eval_suite.py +++ b/scripts/30_eval_suite.py @@ -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__": diff --git a/scripts/35_gate5_penpot_design.py b/scripts/35_gate5_penpot_design.py index 5bf1c36..9133374 100644 --- a/scripts/35_gate5_penpot_design.py +++ b/scripts/35_gate5_penpot_design.py @@ -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": , "log": ""}`. 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 # ------------------------------------------------------------------------------------------