Compare commits

..
10 Commits
Author SHA1 Message Date
aleleba 966811d54f Fase 5: gate2 corregido (max_tokens=2048) - comparacion apples-to-apples
Re-corrida completa con el test corregido (max_tokens=2048, commit
2d2c45f), incluyendo tambien el baseline BF16 re-medido con el MISMO limite
(el original de Fase 4 se midio con max_tokens=1024) para una comparacion
justa:

- BF16 (2048 tokens): 97.5% (195/200), 1 no_tool_call, 4 invalid -- vs
  98.5% (197/200), 0 no_tool_call, 3 invalid del baseline original de Fase
  4 (1024 tokens). El propio BF16 varia levemente al re-medir con mas
  tokens (no-determinismo de vLLM con batching dinamico + mas espacio para
  "reconsiderar" casos ambiguos).
- NVFP4 mezclado (2048 tokens): 96.0% (192/200), 6 no_tool_call, 2 invalid
  -- vs 95.0% (190/200) con 1024 tokens.

Con el mismo limite de tokens, la brecha real BF16 vs NVFP4 se achica de
~4pts (comparacion original, asimetrica) a ~1.5pts (comparacion justa).
Auditados con criterio humano todos los casos no_tool_call/invalid de
ambos: ninguno tiene finish_reason=length (sin truncamiento) -- son
decisiones genuinas del modelo sobre prompts ambiguos (merge condicional
sin instruccion explicita, deteccion de patron DoS en tabla de 371
columnas, falta de contexto real como pageId/spaceId) presentes en AMBOS
checkpoints, no una debilidad especifica de la cuantizacion.
2026-07-30 07:32:50 +00:00
aleleba 2d2c45f0fe Fase 5: gate2 - subir max_tokens a 2048 y guardar texto completo de respuestas
Mismo defecto que se encontro y corrigio en gate3 (commit 77e6804): con
--reasoning-parser activo, max_tokens=1024 podia dejar cortar la respuesta a
mitad de razonamiento antes de emitir el tool_call. Desglose por tipo de
fallo entre corridas: Fase 4 BF16 tuvo CERO casos "no_tool_call" (0/200);
ambas corridas NVFP4 (solo-propia y mezclada) tuvieron 4/200 -- la firma
exacta de un modelo cortado a mitad de razonamiento, no de una regresion de
calidad real.

Mejoras permanentes al test:
1. max_tokens: 1024 -> 2048 (configurable via GATE2_MAX_TOKENS). timeout de
   request subido de 120s a 240s.
2. Se guarda content+reasoning+finish_reason completos en cada fila del
   JSON de resultados (antes solo tool_calls/valid/errors), para poder
   auditar con criterio humano cualquier caso que falle o quede sin
   tool_call, sin tener que re-correr el test.
2026-07-30 06:50:27 +00:00
aleleba bc1638f2da Fase 5: gate3 corregido (max_tokens=2048) - 100% (10/10), regresion era del test
Hipotesis del usuario confirmada: el "FAIL" de aleleba-pr/adherencia en las
corridas NVFP4 anteriores (90%, 9/10) era un defecto del test, no del
modelo. Con max_tokens=512 y --reasoning-parser activo, el modelo (de
razonamiento) agotaba el presupuesto de tokens pensando antes de emitir el
contenido final -- la respuesta quedaba cortada, sin ninguna de las
substrings esperadas.

Re-corrida completa contra el checkpoint NVFP4 con calibracion mezclada
(mismo checkpoint de la comparacion anterior), con el test corregido
(max_tokens=2048): 100% (10/10), IDENTICO al baseline de Fase 4 BF16. El
caso de aleleba-pr ahora pasa (hits=['commit']); el texto completo
(reasoning + content, ahora guardado en el JSON) muestra que el modelo
describe correctamente el flujo (commit con prefijo fix: seguido de
aleleba-pr para armar el PR) -- sin la alucinacion vista antes ("git push
--force", "aleleba-pr-reviewer").

Esto invalida la regresion de la puerta 3 documentada en los hallazgos
anteriores de esta fase -- era un artefacto de medicion, no una
degradacion real de calidad introducida por la cuantizacion.
2026-07-30 06:43:11 +00:00
aleleba 77e6804a4f Fase 5: gate3 - subir max_tokens a 2048 y guardar texto completo de respuestas
Mejoras permanentes al test, no solo para esta corrida:

1. max_tokens: 512 -> 2048 (configurable via GATE3_MAX_TOKENS). Con
--reasoning-parser activo, un modelo de razonamiento puede agotar 512
tokens pensando antes de emitir el contenido final -- la respuesta queda
cortada a mitad de razonamiento y check_adherencia() no encuentra ninguna
substring esperada, un FAIL por presupuesto de tokens agotado, no por
adherencia real. El caso que fallaba en las corridas NVFP4 de Fase 5
(aleleba-pr/adherencia, el prompt mas abierto de los tres) es sospechoso
de este defecto -- el JSON de resultados no guardaba el texto de la
respuesta, asi que no se podia auditar.

2. Guardar content+reasoning completos en cada fila del JSON de resultados
(antes solo se guardaba passed/hits/tool_calls). Permite auditar con
criterio humano cualquier fallo futuro sin tener que re-correr el test.
2026-07-30 06:35:11 +00:00
aleleba 6419646133 Fase 5: puertas 2-3 sobre el checkpoint NVFP4 con calibracion mezclada
Checkpoint cuantizado con calibracion mezclada (256 propias + 256
ultrachat_200k, fix de dos fases), contenedor de eval propio CON
--speculative-config real (levanto healthy, MTP compartiendo embeddings/
lm_head con el modelo target, confirmado en logs).

Puerta 2: 95.0% validos (190/200) vs 94.5% (189/200) solo-propia vs 98.5%
(197/200) Fase 4 -- mejora marginal de +0.5pt sobre solo-propia, sigue
~3.5pts debajo de Fase 4. Persiste el mismo caso de nombre de tool
alucinado (getJiraProjectIssueTypes, no existe) visto en el intento
solo-propia.

Puerta 3: 90.0% (9/10), IDENTICO a solo-propia -- mismo caso exacto falla
(aleleba-pr/adherencia). La diversidad de ultrachat NO corrigio esta
regresion especifica.

Conclusion: la hipotesis de diversidad tematica en la calibracion no
resuelve la regresion observada. El checkpoint mezclado queda como una
alternativa equivalente (no mejor, no peor de forma significativa) al de
solo-datos-propios.
2026-07-30 06:20:58 +00:00
aleleba be90e51214 Fase 5: 21_quantize_nvfp4.py - separar preparacion de calibracion en proceso aparte
Correccion de diagnostico: la conclusion anterior ("incluir ultrachat_200k
dispara el OOM") era incorrecta. Evidencia: la corrida v2 (NUM_CALIBRATION_
SAMPLES=1024, SIN ultrachat) crasheo con el mismo CUDA OOM exacto, en el mismo
punto exacto del setup de oneshot() (disable_lm_head onload), mientras que la
corrida v3 (512 muestras propias) habia progresado bien mas alla de ese mismo
punto (trace_subgraphs completo) antes de ser detenida manualmente. El patron
real es presion de memoria total acumulada en el pool unificado del GB10
(modelo mmap'd de 67GB + construccion del checkpoint cuantizado + allocations
CUDA + maquinaria de `datasets`/pyarrow para ultrachat), no una propiedad
especifica de ultrachat_200k.

Fix: separar la preparacion de datos de calibracion (que puede requerir
`datasets`/streaming/red para ultrachat) de la cuantizacion (que carga el
modelo completo) en dos procesos distintos. --prepare-calibration construye y
guarda a disco (CALIBRATION_CACHE_PATH) la muestra ya tokenizada SIN cargar el
modelo; la cuantizacion normal detecta el cache y lo carga desde disco (sin
volver a tocar `datasets`/red) antes de cargar el modelo. Se agrega tambien un
gc.collect() explicito antes de cargar el modelo.
2026-07-30 05:09:22 +00:00
aleleba b246f8d97a Fase 5: 21_quantize_nvfp4.py - cargar ultrachat_200k en modo streaming
3 intentos seguidos de calibracion mezclada (256 ultrachat + 256 propias)
crashearon con el mismo CUDA OOM reproducible, siempre en el mismo punto
exacto (setup interno de oneshot(): trace_subgraphs/disable_lm_head), tanto
con MAX_SEQUENCE_LENGTH=8192 como =2048 -- descartando el largo de secuencia
como causa. La unica variable real frente a los intentos que SI funcionaron
(256 muestras solo propias) es la inclusion de ultrachat_200k.

Causa raiz identificada: load_dataset(..., split="train_sft") sin streaming
materializa el split completo (~208k ejemplos) como Arrow local, y ademas
genera las 4 splits del repo (~2.9GB en disco). En este hardware (GB10,
memoria unificada CPU/GPU) ese cache extra parece ser suficiente para
empujar el proceso sobre el limite justo en el momento de mayor presion de
memoria del setup de oneshot(). Fix: cargar con streaming=True + shuffle de
buffer + take(n), que solo trae los N ejemplos necesarios sin materializar
el dataset completo -- probado de forma aislada (256 ejemplos en ~12s, sin
crecimiento de cache en disco).
2026-07-30 04:00:07 +00:00
aleleba 78d9b0d90d Fase 5: 21_quantize_nvfp4.py - soporte para calibracion mezclada con ultrachat_200k
Nueva variable NUM_ULTRACHAT_SAMPLES (default 0, sin cambio de comportamiento):
cuando > 0, mezcla esa cantidad de muestras de HuggingFaceH4/ultrachat_200k
(split train_sft, el mismo corpus/split que uso RedHatAI en su receta de
referencia) con (NUM_CALIBRATION_SAMPLES - NUM_ULTRACHAT_SAMPLES) muestras de
TRAIN_DATA_PATH, concatenadas y mezcladas (shuffle, seed=42) antes de
tokenizar para calibracion.

Hipotesis a probar (decision del usuario tras ver que el aislamiento sin
--speculative-config descarto al speculative decoding como causante de la
regresion, y antes de simplemente aumentar la cantidad de muestras propias):
la regresion podria venir de poca DIVERSIDAD tematica en la calibracion
(solo conversaciones angostas de los 5 MCPs/skills del proyecto) en vez de
poca cantidad de muestras. La porcion de TRAIN_DATA_PATH sigue usando el
mismo seed=42, asi que con NUM_ULTRACHAT_SAMPLES=256 y
NUM_CALIBRATION_SAMPLES=512, las 256 muestras propias son identicas a las
del primer intento (256 solo propias).
2026-07-30 03:17:12 +00:00
aleleba 6f1db06f06 Fase 5: aislamiento - puertas 2-3 sin --speculative-config (mismo checkpoint)
Diagnostico pedido por el usuario antes de invertir tiempo en recalibrar:
mismo checkpoint NVFP4 (256 muestras), mismo contenedor de eval, unica
diferencia es remover --speculative-config (servicio vllm-eval-nvfp4-nospec,
puerto 8003).

Resultado: la regresion persiste casi identica sin speculative-config.
Puerta 2: 95.5% (191/200) sin spec vs 94.5% (189/200) con spec vs 98.5%
(197/200) de Fase 4 -- diferencia de 1pt entre con/sin spec, dentro de
ruido esperado; ambas configuraciones quedan ~3-4pts debajo de Fase 4.
Puerta 3: 90% (9/10) sin spec, identico a 90% (9/10) con spec -- mismo caso
puntual falla en ambas corridas (aleleba-pr/adherencia), aunque el texto
alucinado especifico difiere entre corridas (no determinismo esperable de
vLLM con batching dinamico incluso a temperature=0).

Conclusion: el speculative decoding (MTP) NO es el causante de la
regresion -- persiste identica sin el. El causante es la cuantizacion
NVFP4 en si (probablemente calibracion insuficiente de los 256 expertos
MoE con solo 256 muestras). Recalibrar con mas muestras, como estaba
previsto condicionalmente, es ahora el paso indicado.
2026-07-30 02:54:38 +00:00
aleleba d1e9892911 Fase 5: servicio de diagnostico vllm-eval-nvfp4-nospec (puerto 8003)
Identico a vllm-eval-nvfp4 pero sin --speculative-config, para aislar si la
regresion de calidad observada en las puertas 2-3 (vs. Fase 4) viene del
speculative decoding (MTP) o de la cuantizacion NVFP4 en si -- decision
explicita del usuario antes de invertir tiempo en recalibrar con mas
muestras de calibracion.
2026-07-30 01:10:36 +00:00
11 changed files with 9083 additions and 16 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
{
"eval": [
{
"skill": "aleleba-pr",
"kind": "adherencia",
"prompt": "Termine de arreglar el bug del login, aplica aleleba-pr para subir esto.",
"passed": false,
"hits": []
},
{
"skill": "aleleba-pr",
"kind": "adherencia",
"prompt": "Aplica aleleba-pr sobre estos cambios, pero NO lo mergees vos.",
"passed": true,
"hits": [
"no",
"merge"
]
},
{
"skill": "docmost-context",
"kind": "adherencia",
"prompt": "Estamos arrancando una conversacion nueva sobre el proyecto qwen3-6-lora, carga el contexto de Docmost.",
"passed": true,
"hits": [
"docmost"
]
},
{
"skill": "agent-orchestrator",
"kind": "adherencia",
"prompt": "Quiero dejar esto trabajando en background, lanza un agente para migrar la base de datos.",
"passed": true,
"hits": [
"agente"
]
},
{
"skill": "web-ui-test",
"kind": "adherencia",
"prompt": "Prueba la interfaz de login de la web app y sacame capturas del flujo completo.",
"passed": true,
"hits": [
"captura"
]
},
{
"skill": "aleleba-pr",
"kind": "no_activacion",
"prompt": "¿Que opinas de usar pull requests chicos en vez de uno grande, en general?",
"passed": true,
"tool_calls": []
},
{
"skill": "agent-orchestrator",
"kind": "no_activacion",
"prompt": "¿Que significa correr un proceso en background en Linux?",
"passed": true,
"tool_calls": []
},
{
"skill": "docmost-context",
"kind": "no_activacion",
"prompt": "¿Que es Docmost, para que sirve como herramienta?",
"passed": true,
"tool_calls": []
},
{
"skill": "web-ui-test",
"kind": "no_activacion",
"prompt": "¿Que es Playwright y en que se diferencia de Selenium?",
"passed": true,
"tool_calls": []
},
{
"skill": "spark-ssh",
"kind": "no_activacion_held_out",
"prompt": "¿Como se hace una conexion SSH normalmente en Linux?",
"passed": true,
"tool_calls": []
}
],
"baseline": null,
"baseline_disponible": false
}
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
{
"eval": [
{
"skill": "aleleba-pr",
"kind": "adherencia",
"prompt": "Termine de arreglar el bug del login, aplica aleleba-pr para subir esto.",
"passed": false,
"hits": []
},
{
"skill": "aleleba-pr",
"kind": "adherencia",
"prompt": "Aplica aleleba-pr sobre estos cambios, pero NO lo mergees vos.",
"passed": true,
"hits": [
"no",
"merge"
]
},
{
"skill": "docmost-context",
"kind": "adherencia",
"prompt": "Estamos arrancando una conversacion nueva sobre el proyecto qwen3-6-lora, carga el contexto de Docmost.",
"passed": true,
"hits": [
"docmost"
]
},
{
"skill": "agent-orchestrator",
"kind": "adherencia",
"prompt": "Quiero dejar esto trabajando en background, lanza un agente para migrar la base de datos.",
"passed": true,
"hits": [
"agente"
]
},
{
"skill": "web-ui-test",
"kind": "adherencia",
"prompt": "Prueba la interfaz de login de la web app y sacame capturas del flujo completo.",
"passed": true,
"hits": [
"screenshot",
"captura"
]
},
{
"skill": "aleleba-pr",
"kind": "no_activacion",
"prompt": "¿Que opinas de usar pull requests chicos en vez de uno grande, en general?",
"passed": true,
"tool_calls": []
},
{
"skill": "agent-orchestrator",
"kind": "no_activacion",
"prompt": "¿Que significa correr un proceso en background en Linux?",
"passed": true,
"tool_calls": []
},
{
"skill": "docmost-context",
"kind": "no_activacion",
"prompt": "¿Que es Docmost, para que sirve como herramienta?",
"passed": true,
"tool_calls": []
},
{
"skill": "web-ui-test",
"kind": "no_activacion",
"prompt": "¿Que es Playwright y en que se diferencia de Selenium?",
"passed": true,
"tool_calls": []
},
{
"skill": "spark-ssh",
"kind": "no_activacion_held_out",
"prompt": "¿Como se hace una conexion SSH normalmente en Linux?",
"passed": true,
"tool_calls": []
}
],
"baseline": null,
"baseline_disponible": false
}
+53
View File
@@ -80,3 +80,56 @@ services:
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
# la cuantizacion NVFP4 en si. Puerto 8003 (distinto de 8000 produccion, 8001
# Fase 4, 8002 Fase 5 con speculative real). Servicio temporal de diagnostico,
# no clona produccion 1:1 a proposito (esa es la variable que se esta aislando).
vllm-eval-nvfp4-nospec:
image: vllm/vllm-openai:cu130-nightly-aarch64
container_name: vllm-eval-nvfp4-nospec
restart: "no"
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
ports:
- "8003:8000"
ipc: host
ulimits:
memlock: -1
stack: 67108864
volumes:
- /home/aleleba/ft-models/Qwen3.6-35B-A3B-mcp-NVFP4:/models/Qwen3.6-35B-A3B-mcp-NVFP4:ro
command: >
--model /models/Qwen3.6-35B-A3B-mcp-NVFP4
--served-model-name qwen3.6-35b-a3b-mcp-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
+140 -10
View File
@@ -13,6 +13,14 @@ 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
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
tokenizar -- experimento para probar si la regresion de calidad viene de poca
DIVERSIDAD tematica en la calibracion (solo conversaciones angostas de los 5
MCPs/skills) en vez de poca CANTIDAD de muestras.
Algoritmo:
1. Cargar Qwen3_5MoeForConditionalGeneration.from_pretrained(MODEL_PATH,
dtype="auto") + AutoProcessor.from_pretrained(MODEL_PATH).
@@ -42,6 +50,18 @@ Algoritmo:
Soporta --verify-only (o env var VERIFY_ONLY=1) para re-correr solo las
verificaciones sobre un OUTPUT_PATH ya generado, sin repetir la calibracion.
Soporta --prepare-calibration (o env var PREPARE_CALIBRATION=1) para construir y
guardar en disco (CALIBRATION_CACHE_PATH) la muestra de calibracion ya tokenizada,
SIN cargar el modelo. Corre esto en un proceso APARTE, antes de la cuantizacion
normal (que detecta el cache y lo carga en vez de reconstruirlo). Motivo: cargar
`datasets`/streamear ultrachat_200k y cargar el modelo de 67GB en el MISMO proceso
empuja la presion de memoria del pool unificado del GB10 justo al borde --
confirmado empiricamente que hasta una corrida SIN ultrachat (NUM_CALIBRATION_SAMPLES
=1024, solo TRAIN_DATA_PATH) crasheo con el mismo CUDA OOM que las corridas
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.
"""
import json
import os
@@ -77,8 +97,31 @@ MODEL_PATH = Path(os.environ.get("MODEL_PATH", "/workspace/ft-models/Qwen3.6-35B
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"))
# 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"))
ULTRACHAT_DATASET = "HuggingFaceH4/ultrachat_200k"
ULTRACHAT_SPLIT = "train_sft"
MAX_SEQUENCE_LENGTH = int(os.environ.get("MAX_SEQUENCE_LENGTH", "8192"))
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
# 67GB completo) en el MISMO proceso empuja la presion de memoria del pool
# unificado GB10 justo al borde -- confirmado empiricamente: incluso una corrida
# de solo TRAIN_DATA_PATH con NUM_CALIBRATION_SAMPLES=1024 (sin ultrachat) crasheo
# con el mismo OOM que las corridas mezcladas, en el mismo punto exacto del setup
# de oneshot() (disable_lm_head). CALIBRATION_CACHE_PATH separa ambos pasos en dos
# 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.
CALIBRATION_CACHE_PATH = Path(
os.environ.get("CALIBRATION_CACHE_PATH", "/workspace/ft-models/nvfp4_calibration_cache")
)
# 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
@@ -95,7 +138,7 @@ QUANTIZATION_IGNORE = [
]
def load_calibration_dataset(tokenizer):
def load_train_examples(n):
print(f"[INFO] cargando ejemplos de calibracion desde {TRAIN_DATA_PATH}")
examples = []
with open(TRAIN_DATA_PATH, encoding="utf-8") as f:
@@ -107,21 +150,63 @@ def load_calibration_dataset(tokenizer):
rng = random.Random(CALIBRATION_SEED)
rng.shuffle(examples)
sampled = examples[:NUM_CALIBRATION_SAMPLES]
if len(sampled) < NUM_CALIBRATION_SAMPLES:
sampled = examples[:n]
if len(sampled) < n:
raise AssertionError(
f"se pidieron {NUM_CALIBRATION_SAMPLES} muestras de calibracion pero "
f"{TRAIN_DATA_PATH} solo tiene {len(examples)} ejemplos"
f"se pidieron {n} muestras de {TRAIN_DATA_PATH} pero solo tiene {len(examples)} ejemplos"
)
from collections import Counter
bucket_counts = Counter(ex.get("meta", {}).get("bucket", "?") for ex in sampled)
print(f"[INFO] distribucion de buckets en la muestra de calibracion: {dict(bucket_counts)}")
print(f"[INFO] distribucion de buckets (train.jsonl): {dict(bucket_counts)}")
return sampled
def load_ultrachat_examples(n):
from datasets import load_dataset
# streaming=True: el split train_sft completo tiene ~208k ejemplos (~2.9GB
# materializados como Arrow por load_dataset sin streaming, las 4 splits del
# repo se generan igual). En este hardware (GB10, memoria unificada CPU/GPU)
# ese cache extra resulto ser suficiente para tirar un CUDA OOM reproducible
# durante el setup de oneshot() (trace_subgraphs/disable_lm_head), incluso
# truncando las secuencias a 2048 tokens -- el problema no era el largo de
# secuencia sino la memoria consumida por materializar el dataset completo.
# Con streaming solo se bajan los ~n ejemplos necesarios, sin cache local.
print(f"[INFO] cargando {n} muestras de {ULTRACHAT_DATASET} (split={ULTRACHAT_SPLIT}, streaming)")
ds = load_dataset(ULTRACHAT_DATASET, split=ULTRACHAT_SPLIT, streaming=True)
ds = ds.shuffle(seed=CALIBRATION_SEED, buffer_size=10_000)
examples = [{"messages": row["messages"]} for row in ds.take(n)]
print(f"[INFO] {len(examples)} muestras de {ULTRACHAT_DATASET} cargadas (streaming, sin materializar el dataset completo)")
return examples
def load_calibration_dataset(tokenizer):
num_ultrachat = NUM_ULTRACHAT_SAMPLES
num_train = NUM_CALIBRATION_SAMPLES - num_ultrachat
if num_train < 0:
raise AssertionError(
f"NUM_ULTRACHAT_SAMPLES ({num_ultrachat}) no puede superar "
f"NUM_CALIBRATION_SAMPLES ({NUM_CALIBRATION_SAMPLES})"
)
examples = []
if num_train > 0:
examples.extend(load_train_examples(num_train))
if num_ultrachat > 0:
examples.extend(load_ultrachat_examples(num_ultrachat))
rng = random.Random(CALIBRATION_SEED)
rng.shuffle(examples)
print(
f"[INFO] muestra de calibracion mezclada: {num_train} de {TRAIN_DATA_PATH.name} + "
f"{num_ultrachat} de {ULTRACHAT_DATASET}, {len(examples)} total, orden mezclado (seed={CALIBRATION_SEED})"
)
input_ids_list = []
attention_mask_list = []
for ex in sampled:
for ex in examples:
text = tokenizer.apply_chat_template(
ex["messages"],
tools=ex.get("tools"),
@@ -314,25 +399,70 @@ def parse_args():
default=os.environ.get("VERIFY_ONLY", "") not in ("", "0", "false", "False"),
help="saltar calibracion/guardado, solo re-correr las verificaciones sobre OUTPUT_PATH ya existente",
)
parser.add_argument(
"--prepare-calibration",
action="store_true",
default=os.environ.get("PREPARE_CALIBRATION", "") not in ("", "0", "false", "False"),
help=(
"solo construir y guardar en disco (CALIBRATION_CACHE_PATH) la muestra de "
"calibracion ya tokenizada, SIN cargar el modelo -- correr en un proceso aparte "
"antes de la cuantizacion, para que `datasets`/streaming/red nunca compartan "
"proceso con el modelo de 67GB"
),
)
return parser.parse_args()
def prepare_calibration():
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_ULTRACHAT_SAMPLES={NUM_ULTRACHAT_SAMPLES}")
print(f"[INFO] cargando tokenizer desde {MODEL_PATH} (solo tokenizer, no el modelo)")
processor = AutoProcessor.from_pretrained(str(MODEL_PATH), trust_remote_code=True)
tokenizer = processor.tokenizer
calibration_dataset = load_calibration_dataset(tokenizer)
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)")
def main():
args = parse_args()
print(f"[INFO] MODEL_PATH={MODEL_PATH}")
print(f"[INFO] OUTPUT_PATH={OUTPUT_PATH}")
if args.prepare_calibration:
prepare_calibration()
return
if args.verify_only:
print("[INFO] --verify-only: saltando calibracion/guardado, solo verificando OUTPUT_PATH existente")
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}")
print(f"[INFO] CALIBRATION_CACHE_PATH={CALIBRATION_CACHE_PATH}")
print(f"[INFO] cargando processor desde {MODEL_PATH}")
processor = AutoProcessor.from_pretrained(str(MODEL_PATH), trust_remote_code=True)
tokenizer = processor.tokenizer
calibration_dataset = load_calibration_dataset(tokenizer)
if CALIBRATION_CACHE_PATH.exists():
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)")
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}")
print(f"[INFO] NUM_ULTRACHAT_SAMPLES={NUM_ULTRACHAT_SAMPLES}")
print(
f"[INFO] no hay cache en {CALIBRATION_CACHE_PATH} -- construyendo la muestra de "
"calibracion en este mismo proceso (usar --prepare-calibration antes evita esto)"
)
calibration_dataset = load_calibration_dataset(tokenizer)
import gc
gc.collect()
print(f"[INFO] cargando modelo desde {MODEL_PATH} (dtype=auto)")
t_load = time.time()
+23 -3
View File
@@ -35,6 +35,10 @@ HOLDOUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
RESULTS_PATH = REPO_ROOT / "data" / os.environ.get("GATE2_RESULTS_FILENAME", "gate2_results.json")
BASE_URL = os.environ.get("VLLM_EVAL_URL", "http://localhost:8001")
MODEL_NAME = os.environ.get("VLLM_EVAL_MODEL", "qwen3.6-35b-a3b-mcp-bf16")
# 1024 dejaba cortar la respuesta a mitad de razonamiento en modelos con
# --reasoning-parser activo antes de emitir el tool_call -- ver hallazgo de
# Fase 5 (misma causa que el fix de gate3, max_tokens 512->2048).
GATE2_MAX_TOKENS = int(os.environ.get("GATE2_MAX_TOKENS", "2048"))
def load_holdout():
@@ -92,13 +96,13 @@ def validate_tool_call(tool_call, tools):
return True, None
def call_vllm(prompt, tools, timeout=120):
def call_vllm(prompt, tools, timeout=240):
payload = {
"model": MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"tools": to_openai_tools(tools),
"tool_choice": "auto",
"max_tokens": 1024,
"max_tokens": GATE2_MAX_TOKENS,
"temperature": 0.0,
}
resp = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, timeout=timeout)
@@ -133,11 +137,24 @@ def main():
continue
message = response["choices"][0]["message"]
# Se guarda siempre el texto completo (content + reasoning) para poder auditar
# con criterio humano los casos que fallan o quedan sin tool_call -- antes no se
# guardaba nada de esto, lo que hacia imposible diagnosticar truncamiento.
content = message.get("content") or ""
reasoning = message.get("reasoning") or ""
tool_calls = message.get("tool_calls") or []
if not tool_calls:
stats[mcp]["no_tool_call"] += 1
stats["__global__"]["no_tool_call"] += 1
results.append({"mcp": mcp, "prompt": ex["prompt"], "tool_calls": None, "valid": None})
results.append({
"mcp": mcp,
"prompt": ex["prompt"],
"tool_calls": None,
"valid": None,
"content": content,
"reasoning": reasoning,
"finish_reason": response["choices"][0].get("finish_reason"),
})
continue
all_valid = True
@@ -161,6 +178,9 @@ def main():
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
"valid": all_valid,
"errors": errors,
"content": content,
"reasoning": reasoning,
"finish_reason": response["choices"][0].get("finish_reason"),
})
if (i + 1) % 20 == 0:
+21 -3
View File
@@ -33,6 +33,11 @@ EVAL_URL = os.environ.get("VLLM_EVAL_URL", "http://localhost:8001")
EVAL_MODEL = os.environ.get("VLLM_EVAL_MODEL", "qwen3.6-35b-a3b-mcp-bf16")
PROD_URL = os.environ.get("VLLM_PROD_URL", "http://localhost:8000")
PROD_MODEL = os.environ.get("VLLM_PROD_MODEL", "qwen3.6-35b-a3b")
# 512 dejaba cortar la respuesta a mitad de razonamiento en los prompts mas
# abiertos (modelo de razonamiento con --reasoning-parser activo) antes de
# emitir el contenido final -- un FAIL por presupuesto de tokens agotado, no
# por adherencia real. Ver hallazgo de Fase 5.
GATE3_MAX_TOKENS = int(os.environ.get("GATE3_MAX_TOKENS", "2048"))
# Checklists por skill: (skill, tipo, prompt, [substrings esperados en la respuesta, any-of])
CHECKLISTS = [
@@ -103,10 +108,10 @@ def call_model(base_url, model_name, prompt):
payload = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512,
"max_tokens": GATE3_MAX_TOKENS,
"temperature": 0.0,
}
resp = requests.post(f"{base_url}/v1/chat/completions", json=payload, timeout=120)
resp = requests.post(f"{base_url}/v1/chat/completions", json=payload, timeout=240)
resp.raise_for_status()
data = resp.json()
message = data["choices"][0]["message"]
@@ -147,9 +152,20 @@ def run_checklist(base_url, model_name, label):
print(f" [ERROR] {skill}/{kind}: {e}")
continue
# Se guarda siempre el texto completo (content + reasoning) para poder auditar
# con criterio humano los casos que fallan -- antes solo se guardaba passed/hits,
# lo que hacia imposible revisar despues que dijo realmente el modelo.
if kind == "adherencia":
passed, hits = check_adherencia(response, expected)
rows.append({"skill": skill, "kind": kind, "prompt": prompt, "passed": passed, "hits": hits})
rows.append({
"skill": skill,
"kind": kind,
"prompt": prompt,
"passed": passed,
"hits": hits,
"content": response["content"],
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} adherencia hits={hits}")
else:
passed = check_no_activacion(response)
@@ -159,6 +175,8 @@ def run_checklist(base_url, model_name, label):
"prompt": prompt,
"passed": passed,
"tool_calls": [tc["function"]["name"] for tc in response["tool_calls"]],
"content": response["content"],
"reasoning": response["reasoning"],
})
print(f" {'OK ' if passed else 'FAIL'} {skill:20s} {kind:20s} tool_calls={len(response['tool_calls'])}")
return rows