Ran the full agent loop against vllm-qwen36 on port 8000 (read-only HTTP)
with the Penpot plugin live, before asking for any downtime. Without this
file "it improved" would be a claim rather than a measurement.
Result over the 8 graded prompts: mean score 14.9, zero prompts at or above
60, veto violated on 2 of 8, 66% of execute_code calls raised, and 7 of 8
prompts burned all 14 turns without producing a final message.
The plan predicted production would score near zero on distinct colours and
style richness while producing a high shape count - grey boxes. The shape
count is also zero. On a fresh page it creates nothing at all, so the
failure sits upstream of the grey boxes: the model invents a Figma-shaped
API wholesale and every call throws. From the captured turns:
penpot.currentPage() is a property, not a function
penpot.createRectangle(page, 200, 56) takes no arguments
penpot.createText(page, ...) takes one, the text
penpot.getPageById(...) lives on penpotUtils
fills = [{type:'solid', color:{r,g,b,a}}] is {fillColor, fillOpacity}
shadows = [{type:'drop', x, y, blur, ...}] is {style, offsetX, offsetY}
It then spends the remaining turns querying penpot_api_info without
recovering. So the reported symptom understated it.
Two robustness fixes the run itself forced, both after losing a completed
run to them:
- A ConnectionError does not just drop the request, it can drop the MCP
session, so retrying the same tools/call against a dead session fails
identically every time - which is exactly what the first attempt showed,
four retries and four identical ConnectionErrors. The client now redoes
the handshake before retrying, and that recovered two drops in this run.
- Results are written after every prompt. The first attempt died on prompt
4 and lost the three already measured, which is the expensive data
precisely because it requires production to be up.
Replaces the 41 old Penpot seeds with 105 new ones. The old set taught
three API forms that do not exist - findShapeById(page, id), shape.layout,
and createText() with no argument - and 21 of the 41 used the first one, so
patching was never an option: keeping them would mean fine-tuning against
the correction. The valuable lessons were re-founded on the real API
instead (the reversed children array in flex, persisting ids in storage,
never logging what you also return).
105 rather than 96 because nine multi-section compositions are split into
two trajectories each: the first builds the skeleton and persists ids,
palette, scale and helper functions in storage, the second recovers them
and fills the sections. That was forced by the 3000-token ceiling, but it
is better pedagogy anyway, and it is what execute_code's own description
asks for. It also paid for itself: the helpers cost ~600 chars once instead
of twice, and the skeleton call needs no export_shape, which freed the
budget to carry the verbatim system block.
Measured against the linter with the corpus-wide thresholds active:
143 unique code payloads (the old set had 36), 32% of seeds carrying the
server's system block verbatim (the old set had none), and every coverage
category met where the old set had zero addGridLayout, zero shadows, zero
uploadMediaUrl and zero layoutChild.
The flagship changed domain from pizzeria to an empanada shop. Gate 5's
prompt 6 is the exact production failure, and the seed had been written to
the same wording - a literal substring of the gate prompt, sharing two
6-gram shingles where the gate's disjointness check fails on one. Training
on the prompt we then evaluate would make gate 5 measure memorisation. The
real pizzeria prompt still runs in the human acceptance test. The same
check also caught an onboarding seed sitting too close to gate prompt 7.
Mix: 1000 examples split 90/10, giving 901 train and 99 eval. The mix is
1000 rather than 900 because 900 has to be the post-split train size: 900/16
= 56.25 steps per epoch x 3 = 168 steps, deliberately paired with phase 3's
166 so the optimiser trajectory length is comparable. Building 900 and then
carving out eval would have left 811 and 152 steps, silently breaking that
pairing.
Two guards in the builder had to be corrected against the real corpus:
- The forbidden-pattern scan now reads only `code` payloads. Scanning the
serialised example flagged the corrective seeds themselves - the one that
opens with the user asking "Importá esta imagen con import_image", the one
quoting the overview line that mentions import_image verbatim, the one
explaining that board.layout does not exist. They name the wrong API
precisely in order to teach against it.
- The exemption for error-recovery seeds is derived from content, not from
the mix portion: a forbidden pattern is allowed only where its tool result
is a real error string and a later payload does the same thing without it.
Keying on the portion broke as soon as an error-recovery seed lived in
group A1, where the findShapeById arity seed naturally belongs.
Validated with the production tokenizer at preserve_thinking=true: p50 2466,
p90 2988, max 3250 tokens over the 105 seeds. Ten sit just above 3000, so
MAX_TOKENS goes to 3300 for the run and the smoke run will train on the 32
longest examples specifically, turning the peak-memory question into a
ten-minute measurement instead of a risk discovered hours in.
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.
Unlike gates 2-4 this one needs a real agent loop - model, tool call, live
MCP, result, up to 14 turns - because design quality only exists after the
code executes. It talks to vLLM over the OpenAI API and to the Penpot MCP
over HTTP (initialize, notifications/initialized, tools/list, tools/call),
handling both application/json and text/event-stream responses. Endpoints
and credentials come from env with no defaults and are never printed or
stored; requests errors are reduced to the exception type because the
requests message embeds the URL.
Eight graded prompts, a fresh page per prompt named gate5/<tag>/<id>/<ts>,
and the gate never deletes anything. The 17 metrics are computed by an
audit payload the gate injects, not the model. placeholderGreys and
forbidden behaviour are veto metrics: any hit scores that prompt 0.
Two things worth calling out.
The forbidden-pattern regexes are imported from 07_lint_penpot_code.py
rather than duplicated, and the gate runs those same regexes over its own
setup and audit payloads at startup - a gate that violated the API it is
grading would be measuring its own bug.
The holdout mode had a silent failure that is exactly the kind this phase
exists to catch: with the endpoint down, all 60 generations failed, each
entered the denominator with zero forbidden patterns found, and the gate
reported 0% forbidden API and APPROVED. Since that number is the fallback
trigger, a false pass there would have launched the quantization run.
Request errors are now counted separately, never enter the denominator,
and block approval outright.
Known issue, resolved separately: gate prompt 6 is the exact production
failure ("hazme una landing page de una pizzería con colores vibrantes"),
and the flagship B6 seed was written to the same wording. Shingle overlap
measures 20%, under the 34% threshold, but the seed prompt is a literal
substring of the gate prompt - the threshold is too loose for prompts this
short. The seed's domain gets changed rather than the gate's, so gate 5
measures transfer instead of memorisation; the real pizzeria prompt still
runs in the human acceptance test, which is the criterion that decides.
Dataset build (05, 06):
perturb_value is gone. It rewrote only tool_calls.arguments and left the
tool results and the final answer saying something else, which is how
data/train.jsonl ended up with 30 self-contradictory examples where the
call says issue_number 82 and the answer says issue #77. Variation now
comes from hand-written meta.paraphrases, or from meta.variation applied
atomically across every field of the example at once. Nothing is
substituted unless the seed declares it: guessing which number in a string
is safe to change is what produced the contradictions in the first place.
Prefix injection survives only as a fallback and only where the verb form
can actually be conjugated, and there is a hard assert that no user turn
matches the broken "Necesito que ¿Podés..." shape that 68 v1 prompts had.
The penpot bucket is exempt from substitution entirely, since its payloads
are code. Also asserts the bucket cannot collapse (verified: the old seeds
give 320 rows from 83 unique trajectories and the build now fails) and
scans for forbidden API patterns by importing them from the linter, so
there is one source of truth.
06 now actually exits 1 on over-length rows. It printed [FILTERED],
incremented a counter, and left the row in the file, which 10_train.py
then trained on since it has no max_seq_length and batch 1.
Gate 2 (32): reject any argument key absent from the schema, as its own
failure category. It only checked required fields, so an invented scale or
filePath passed - the gate was actively rewarding the exact behaviour this
phase removes. Verified: export_shape with scale=2 now fails as
unknown_argument, while a valid call still passes.
Holdout (31, 35): rebalanced to penpot 60 / 35 each, added 20 real design
templates, and replaced the full-string equality check with 6-gram
shingles. Measured: a light paraphrase of a train.jsonl prompt scores 43%
overlap and now fails the build, where the old check let it through at
"not equal". Value pools are asserted disjoint from the corpus. The
"2x resolution" template stays, relabelled as an invented-argument probe
now that gate 2 can detect one; the createBoolean template stays because
the API is real and the new B2 seeds teach it. Also dedupes: the old
holdout had 15 duplicate prompts out of 200, i.e. 15 wasted measurements.
Note: rebalancing the holdout means the 192/200 gate 2 baseline from phase
5 no longer applies to it, so that baseline has to be re-measured against
production on the new file before it can be compared to.
Gate 3 (33): 11 content checklists for the non-obvious conventions of the
other MCPs - GFM table separators in Docmost, the update_page staleness
retry, commit message shape, never merging the PR, dict-not-XML tool
arguments. That is the most likely regression no gate currently covers.
Mix builder: added the anti-collapse guard, so 420 new-portion rows that
are really 96 trajectories repeated cannot pass unnoticed.
07_build_lora2_mix.py assembles train_lora2.jsonl (900), eval_lora2.jsonl
and calibration_v2.jsonl from the new Penpot seeds plus a filtered replay
sample of data/train.jsonl. It never writes data/train.jsonl or
data/eval.jsonl: those are the provenance of the model in production and
the gate 1 baseline, and regenerating them is not idempotent anyway, since
stratified_split shuffles one RNG over the concatenated list, so touching
the penpot bucket reshuffles every other bucket's split too.
Two things worth flagging in the mix:
The 45 "corrected penpot basics" the plan lists inside the replay portion
do not come from data/train.jsonl. 21 of its 41 penpot seeds teach
findShapeById(page, id) and 5 use shape.layout, so sampling that bucket
would re-teach the exact bug this phase removes; the forbidden-pattern
filter would drop them anyway. They come from the new corpus instead. This
is a conscious deviation from the plan text and is recorded in the
docstring.
Variation comes only from hand-written meta.paraphrases, never from
automatic value substitution. That is the v1 lesson: perturb_value rewrote
only tool_calls.arguments and left the tool results and the final answer
saying something else, producing 30 self-contradictory examples. A
perturbed Penpot code payload is just broken code.
Linter fixes, both false positives found by running it against the real
corpus:
- flex evidence for a bare appendChild is now scoped to the whole seed
rather than the single payload. A multi-call seed builds the flex board
in call one and stashes helpers in storage, so by the time call two does
main.appendChild(...) neither addFlexLayout( nor .flex appears in that
payload. The old scope flagged exactly the storage-persistence pattern
that execute_code's own description asks for.
- a grey hex is a problem when it is applied, not when it is searched for.
The repair seeds have to name the greys they are about to replace, so
greys are allowed in that group inside a comparison context.
The training container from phases 3-5 no longer exists and nothing in the
repo pinned its versions, so a rebuild could silently change either the
checkpoint key conversion (breaking adapter naming) or the assistant-mask
behaviour (training on system/user/tool tokens). requirements.train.txt
pins what matters and documents the two-phase install: llmcompressor
declares torch>=2.10.0 and the NGC image ships the 2.10.0a0 pre-release,
which pip's resolver reads as older, so it goes in with --no-deps.
Pre-flight verified against the merged bf16 checkpoint on spark:
01_inspect_modules.py prints model.layers.0.linear_attn.*, config.json is
sha256-identical to the base (93a4693f...), and the index keysets match
exactly (1045 tensors, 690 under model.language_model.layers.*, 0 under
model.layers.*). So PEFT will name adapter #2 the same way it named #1 and
ADAPTER_TO_CHECKPOINT_PREFIX in 20_merge_lora.py applies unchanged.
10_train.py: every path and hyperparameter moves to an env var, with the
phase 3 values as defaults so a bare run still reproduces phase 3 exactly.
Adds three guards that each cover a specific silent failure:
- abort if OUTPUT_DIR already holds an adapter, unless ALLOW_OVERWRITE=1.
OUTPUT_DIR was hardcoded to out/lora-adapter, which is the provenance of
the model currently in production.
- MAX_TOKENS aborts rather than truncates. There was no length filter at
all, so one long design trajectory would blow the memory budget hours
into a run; truncating would be worse, since it would silently cut
assistant targets.
- assert use_rslora/use_dora/bias/modules_to_save. rsLoRA scales by
alpha/sqrt(r), so an adapter trained with it would merge at 2.0 where
11.3 belongs and pass every assertion in the merge script.
Also adds a config banner, a token-length histogram, and a per-bucket
assistant-mask ratio report.
New 07_lint_penpot_code.py hard-fails on the forbidden API patterns,
placeholder greys, fabricated penpot_api_info results, toy-shaped ids and
per-category coverage shortfalls. Error-recovery seeds legitimately need
the wrong pattern, so the exemption is derived mechanically rather than
declared by hand: a payload may contain a forbidden pattern only if its
tool result is a real error string from the allow-list and a later payload
in the same seed does the same thing without it.
Run against the 41 existing seeds it reproduces the diagnosis exactly:
110 problems, 36 unique payloads, 0% system messages, zero coverage of
addGridLayout/shadows/uploadMediaUrl/layoutChild, fabricated docs and
toy ids.
The 41 existing Penpot seeds contain hand-fabricated penpot_api_info and
high_level_overview tool results that assert facts the server never said,
which is how the model learned an API that does not exist. This adds four
schema files that make the seed corpus mechanically verifiable against the
real server instead.
- penpot_api_docs.md: 34 verbatim captures of high_level_overview and
penpot_api_info, each headed by the exact request that produced it. Every
penpot_api_info tool result in a seed must be a subset of lines of this
file, in original order. Records three places where the served docs
contradict the runtime (addFlexLayout/addGridLayout copy-paste in the Grid
section, flex.appendChild for grid children, withChildren vs
includeChildren), plus the createText() example that is the direct cause
of the production failure.
- penpot_system_prompt.md: the server's instructions block verbatim. Goes as
a system message into ~30% of the new seeds; it is the countermeasure to
the "don't pick your own colours" rule that produces the grey boxes.
- penpot_errors.md: the real error strings, including a section on silent
failures that raise nothing at all and are why the read-back invariant
exists.
- PENPOT_API_VERIFIED.md: the allow-list. No seed may reference a member
absent from it. Documents the four root causes (findShapeById arity 1,
no shape.layout, createText() returning null, the #B1B2B5 default fill),
the twelve anti-grey-box invariants, and the forbidden-pattern list the
linter checks.
Live re-verification of the error strings is still pending: the Penpot
plugin is not currently connected, so it is deferred to the gate 5 baseline
step, which needs the live connection anyway.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
Contenedor de eval propio (vllm-eval-nvfp4, puerto 8002) levanto healthy y
sirvio con --speculative-config real (mtp, num_speculative_tokens=1) sin
errores -- confirma que los tensores MTP reinyectados calzan correctamente
con vLLM (puerta 0 superada).
Puerta 2 (tool-calls, 200 prompts held-out): 94.5% validos (189/200) vs
98.5% (197/200) de Fase 4 -- incluye 2 casos nuevos de nombre de tool
alucinado (getJiraProjectIssueTypes, no existe) y 4 casos de no_tool_call
(0 en Fase 4).
Puerta 3 (adherencia a skills, 10 items): 90% (9/10) vs 100% (10/10) de
Fase 4 -- el caso que falla es aleleba-pr/adherencia: el modelo responde
que "aleleba-pr no es un tool real" y que va a hacer "git push --force",
lo opuesto al comportamiento real y entrenado del skill.
Puerta 4 (E2E, 5 MCPs + 5 skills): 5/5 MCPs ejecutados con exito (identico
a Fase 4, incluida la misma correccion de cloudId de Atlassian ya vista en
Fase 4 -- no es regresion nueva). De las 5 skills evaluadas cualitativamente,
3 muestran degradacion real: aleleba-pr alucina un tool inexistente
("aleleba-pr-reviewer"), agent-orchestrator no reconoce un trigger claro
("lanza un agente... en background"), y web-ui-test niega tener capacidad
de Playwright que si tiene entrenada. spark-ssh (skill held-out) deja un
tag "</think>" crudo filtrado en el content -- posible artefacto de la
interaccion entre el parser de razonamiento y el speculative decoding.
Regresion real y no trivial vs. Fase 4 en las 3 puertas. Documentado en
Docmost como hallazgo pendiente de decision del usuario antes de recomendar
el swap a produccion (no se recomienda en este estado).
La verificacion automatica asumia (siguiendo el layout del checkpoint de
referencia de RedHatAI) que save_pretrained() separaria vision a su propio
model_visual.safetensors. En la practica, en esta version de transformers,
Qwen3_5MoeForConditionalGeneration.save_pretrained() escribe lenguaje+vision
juntos en el/los shard(s) de model.safetensors -- comportamiento igualmente
valido (el index.json mapea cada tensor a su shard real sin importar el
nombre de archivo). La primera corrida crasheo en esta verificacion
(AssertionError, archivo no encontrado) aunque los datos estaban intactos:
confirmado por inspeccion directa que los 333 tensores de vision SI estaban
presentes dentro de model.safetensors. Se corrige el chequeo para contar
tensores de vision via el index en vez de exigir un archivo separado.
Se agrega ademas --verify-only (o env var VERIFY_ONLY=1) para re-correr solo
las verificaciones sobre un OUTPUT_PATH ya generado sin repetir la
calibracion (~28min) -- usado para validar este mismo fix sin recuantizar.
Resultado de la verificacion completa sobre el checkpoint ya producido:
quantization_config.format=nvfp4-pack-quantized, model_mtp.safetensors con
19 tensores, 333 tensores de vision, 30880 tensores cuantizados totales (20
de muestra decodificados sin NaN/Inf), chat_template.jinja identico al de
produccion, tamano total 23.35GB.
Para reusar los scripts de Fase 4 contra el endpoint NVFP4 nuevo sin
sobreescribir los resultados de Fase 4 (data/gate{2,3,4}_results.json, ya
commiteados como baseline de comparacion). Default sin cambios cuando la
env var no esta seteada.
Clona 1:1 el docker-compose.yml real de produccion de vllm-qwen36 (citado
integro en PLAN.md): mismos flags de vLLM incluido --speculative-config
(mtp, num_speculative_tokens=1) -- la primera vez que se prueba en este
proyecto -- --quantization compressed-tensors, --moe-backend
flashinfer_cutlass, --kv-cache-dtype fp8_e4m3, --hf-overrides de rope
scaling, parsers de reasoning/tool-call, y el resto de flags identicos.
Solo cambia container_name, puerto (8002 vs 8000 de produccion y 8001 del
vllm-eval de Fase 4), volumen (checkpoint NVFP4 de Fase 5),
--model/--served-model-name, y restart: "no". Nunca toca vllm-qwen36 ni su
compose real de Portainer.
llmcompressor 0.12.0 (ultima version en PyPI) importa incondicionalmente
GraniteMoeParallelExperts al armar su registro interno de arquitecturas MoE
linearizables, incluso para modelos que no son GraniteMoe. transformers 5.14.1
renombro esa clase a GraniteMoeExperts, lo que rompia oneshot() para
cualquier modelo (incluido este Qwen3.5 MoE, que ni siquiera esta en ese
registro). Alias minimo antes de importar llmcompressor para que el import
no explote; nunca se usa en la practica ya que Qwen3.5 MoE no matchea esa
entrada del registro.
Clona la receta exacta de RedHatAI/Qwen3.6-35B-A3B-NVFP4 (QuantizationModifier
targets=Linear scheme=NVFP4, ignore list identica) sobre el checkpoint mergeado
de Fase 4. Diferencia obligatoria: calibra con una muestra de data/train.jsonl
(chat template de produccion) en vez de ultrachat_200k. moe_calibrate_all_experts=True
para cubrir los 256 expertos ruteados. Reinyecta MTP via
save_mtp_tensors_to_checkpoint y deja que save_pretrained separe vision a su
propio shard. Verificaciones automaticas: quantization_config.format,
conteo de tensores MTP/vision, muestra sin NaN/Inf tras des-cuantizar,
chat_template.jinja de produccion preservado.
Puerta 2 (tool-calls, 200 prompts held-out, parser real qwen3_coder de vLLM): 197/200
validos (98.5%). Los 3 invalidos son casos donde el prompt referencia un recurso por
nombre (space/repo) sin ID real -- el modelo elige la tool correcta pero omite un campo
requerido (spaceId/repo) que no puede conocer en un turno unico sin una llamada previa de
lookup; no es un fallo de sintaxis del parser.
Puerta 3 (adherencia por skill + no-activacion, 10 items sobre las 5 skills reales):
100% de aprobacion. vllm-qwen36 no estaba corriendo -- baseline de produccion documentado
como pendiente, no bloqueante.
Puerta 4 (E2E real contra los 5 MCPs, ejecutado por el agente orquestador con sus propios
MCPs conectados): 5/5 exitosos. El unico caso que requirio una segunda llamada fue
atlassian (el modelo adivino un cloudId plausible que no era el real -- se corrigio con
getAccessibleAtlassianResources y la llamada tuvo exito, comportamiento esperado en un
flujo multi-turno).
El primer resultado (promedio simple por ejemplo) daba 0.5185 vs 0.275 de Fase 3, señal de
alarma segun el propio script. La causa era metodologica, no un bug de merge: el bucket
replay concentra 112927 de los ~128849 tokens assistant del split de eval (87%), mientras
que buckets dificiles como negativos/skills_adherencia/delegacion_subagentes tienen pocos
ejemplos pero loss alto -- un promedio por ejemplo les da el mismo peso que a replay,
inflando el global. transformers.Trainer pondera por token, no por ejemplo. Con el mismo
ponderado por token: 0.2560 vs 0.275 de Fase 3 (diff=0.019, dentro del margen esperado) --
confirma que el merge es correcto.
- scripts/30_eval_suite.py --gate 1: eval-loss sobre el checkpoint mergeado, agrupado por
meta.bucket (aislando replay), comparado contra eval_loss=0.275 de Fase 3.
- docker-compose.eval.yml: servicio vllm-eval propio (puerto 8001), sirviendo el
checkpoint mergeado en BF16, con tool-call-parser=qwen3_coder y reasoning-parser=qwen3.
No se pudo leer el compose real de produccion (/data/compose/43/docker-compose.yml no
existe en spark, probablemente vive en el host del servidor Portainer) -- flags basados
en la arquitectura conocida del modelo.
- scripts/31_build_holdout_prompts.py: genera data/holdout_prompts.jsonl (200 prompts,
40 por MCP, sin overlap verificado contra train.jsonl/eval.jsonl).
- scripts/32_gate2_toolcalls.py: valida tool-calls devueltas por vllm-eval (parser real
de vLLM, nunca una regex propia) contra los 200 prompts held-out.
- scripts/33_gate3_adherencia.py: checklists de adherencia por skill + no-activacion,
con baseline opcional contra vllm-qwen36 si esta corriendo.
- scripts/34_gate4_e2e.py: arma el plan de llamadas E2E contra los 5 MCPs y 5 skills via
el checkpoint mergeado, para que el agente orquestador las ejecute con sus MCPs reales.
Remapea nombres de modulo (model.layers.* del adapter -> model.language_model.layers.*
del checkpoint base multimodal), preserva tensores mtp.*/visual.* al copiarlos sin
modificar, y toma chat_template.jinja de MODEL_PATH (nunca del adapter, que tiene el
template de masking de training). Verificacion automatica en verde: 310/310 tensores
LoRA-target mergeados, 1045/1045 tensores totales preservados, sin NaN/Inf, muestra de
200 tensores no-target byte-identica al base.
Los checkpoints intermedios (checkpoint-100/150/166, con optimizer.pt de
resumen de training) quedan ignorados -- no aportan nada mas alla del
peso final ya versionado aqui, y triplicarian el tamano sin necesidad.
El chat_template.jinja de produccion no tiene tags {% generation %}, por lo que
return_assistant_tokens_mask salia vacio para el 100% de los ejemplos en el primer run.
Se genero data/chat_template_train.jinja (copia exacta del template real, con {%- generation -%}
envolviendo solo el contenido/tool_calls/im_end de cada turno assistant) para el fallback
de masking ya anticipado en la Decision de diseno #4 del plan principal -- el chat_template.jinja
original no se toca, sigue siendo el que sirve produccion.
data/raw/replay.jsonl: 80 prompts semilla (conversacion general/codigo/
razonamiento) x 9 pasadas variando temperatura, contra vLLM de produccion
(vllm-qwen36), duracion real ~5h07min. Revisado: 720/720 lineas son JSON
valido con messages de 2 turnos y reasoning_content no vacio; 23 ejemplos
quedaron con content vacio por agotar el presupuesto de max_tokens durante
el razonamiento (finish_reason=length), marcados en meta para que la Fase 2
decida como tratarlos.
.gitignore: excepcion para versionar replay.jsonl pese a vivir en data/raw/,
como especifica el plan (es texto revisable, no un binario de checkpoint).
El script debe correr directo en el host de spark (sin contenedor), donde /workspace no existe.
Corrige a la ruta real del repo en el NFS compartido: /mnt/docker-nas/projects/ai-projects/qwen3-6-lora/data/raw/replay.jsonl
data/schemas/*.json: dump fiel de las tool definitions reales de
Penpot (4), Gitea (53), GitHub-personal (43), Docmost (17) y
Atlassian (37), obtenidas directo de las definiciones ya cargadas
en la sesion de Claude Code (no se escribio un cliente MCP nuevo,
para no arriesgar desviarse del esquema real).
PENPOT_DEPLOYMENT_NOTES.md: investigacion del codigo fuente oficial
de @penpot/mcp confirma que import_image y export_shape.filePath
estan ausentes porque este deployment corre en modo remoto/multi-
usuario (isFileSystemAccessEnabled() = !isRemoteMode()) -- documentado
tambien en una subpagina nueva de Docmost.
scripts/03_build_replay.py: genera ~700 ejemplos de replay (anti-
forgetting) contra el vLLM de produccion, 80 prompts semilla
(conversacion general/codigo/razonamiento) x 9 pasadas variando
temperatura. Mapea el campo "reasoning" de la API de vLLM a
"reasoning_content" para la convencion de chat template.
00_verify_hardware.py: flash_attn, sdpa y flash-linear-attention
funcionan los tres en el GB10 (SM121) via Triton JIT -- mejor de lo
esperado, ya no hace falta el fallback lento de PyTorch para Gated
DeltaNet. attn_implementation recomendado: flash_attention_2.
01_inspect_modules.py: resuelve la discrepancia de nombres de las
proyecciones de Gated DeltaNet inspeccionando la arquitectura real
(device_map=meta, sin pesos). Resultado: parcialmente fusionado --
in_proj_qkv (q+k+v en un solo Linear) pero in_proj_z, in_proj_a e
in_proj_b por separado. Confirma tambien que mlp.experts.{gate_up,down}_proj
son nn.Parameter 3D (no LoRA-ables) y que shared_expert.{gate,up,down}_proj
son nn.Linear normales, como anticipaba el plan.
constraints.txt: fija la version exacta de torch de la imagen NGC
(2.10.0a0+b4e4ee81d3.nv25.12) para instalar transformers/peft/trl/
accelerate/bitsandbytes sin romper el build ARM64/Blackwell.
Scaffolding inicial del repo: carpetas scripts/, data/schemas/,
data/raw/, out/, .gitignore para binarios/checkpoints, y el
docker-compose.yml del contenedor de training (imagen NGC pytorch
25.12-py3, GPU reservada, bind mounts a ai-projects vía NFS y a
~/ft-models en disco local rápido de spark) sin tocar jupyter-pyt
ni el vllm de producción.