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