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