CreaRack-SL

Incident s49 — Varianza extrema Auto-Plan en Vertex (C1+C2+C3)

Incident s49 — Varianza extrema Auto-Plan en Vertex (C1+C2+C3)

Resumen ejecutivo

El audit interno s49 detectó una cascada de 3 bugs críticos en el pipeline Auto-Plan que explicaban por qué la misma configuración de blueprint producía resultados completamente distintos entre ejecuciones consecutivas contra el modelo Vertex/Gemma 4 en OpenRouter: 45, 60 o 78 racks con prompt y seed idénticos.

  • Detectado: 2026-05-04 (audit s49)
  • Fix mergeado: PR#19 · 2026-05-04T12:50:11Z
  • Archivos: blueprints/services/autoplan.py, blueprints/services/openrouter_driver.py
  • Impacto: reproducibilidad nula de Auto-Plan con cualquier proveedor LLM vía OpenRouter
  • Severidad: Alta — output funcional impredecible para el usuario final

Cronología

MomentoEvento
s46Primeros glitches JSON detectados en Gemma 4 con outputs >70 elementos (plano 70)
s49Audit identifica cascada de 3 bugs. Varianza confirmada: 45/60/78 racks misma config
PR#19 mergeFixes C1+C2+C3 aplicados en un único commit a main

Bugs raíz

C1 — Mutación de seed en retries sin rastreo

Síntoma: En el bucle de retries por JSONDecodeError, el seed en create_kwargs se incrementaba correctamente en el retry, pero la lógica era implícita: si json_attempt == 0 no se tocaba el seed, pero el seed podía no estar en create_kwargs desde el inicio dependiendo de si seed_raw estaba vacío.

Causa: seed se usaba como variable única sin separar la semilla base del desplazamiento de intento. Tras un retry, seed + json_attempt se calculaba sobre la variable local ya modificada en iteraciones anteriores.

Fix: Se introduce base_seed (fijo, leído de env) y effective_seed = base_seed + json_attempt calculado explícitamente en cada iteración. El seed efectivo se loggea siempre en el diagnóstico.

# Antes (buggy):
seed = int(seed_raw) if seed_raw.strip() else None
# ...
if json_attempt > 0 and seed is not None:
    create_kwargs["seed"] = seed + json_attempt  # mutaba la var local

# Después (fix C1):
base_seed = int(seed_raw) if seed_raw.strip() else None
# ...
effective_seed = (base_seed + json_attempt) if base_seed is not None else None
if effective_seed is not None:
    create_kwargs["seed"] = effective_seed
elif "seed" in create_kwargs:
    del create_kwargs["seed"]

C2 — allow_fallbacks=True permitía fallback silencioso a otro provider

Síntoma: Aunque el provider_order apuntaba a Vertex, OpenRouter podía silenciosamente redirigir la petición a otro provider (con diferente comportamiento estocástico) sin que el log lo evidenciara. Esto explicaba la varianza extrema entre ejecuciones.

Causa: El campo extra_body.provider.allow_fallbacks estaba hardcodeado a True. Cuando Vertex caía o superaba cuota, OpenRouter reasignaba a otro provider con distribución de probabilidad distinta — haciendo irreproducible el resultado incluso con el mismo seed.

Fix:

  • allow_fallbacks por defecto False, configurable via env OPENROUTER_ALLOW_FALLBACKS
  • require_parameters: True añadido — rechaza providers que no soporten seed/top_k
  • Extracción del provider real desde la respuesta mejorada: model_dump() + model_extra fallback
  • Log diagnóstico envuelto en try/except para no enmascarar errores de parsing
  • Warning explícito si finish_reason == "length" (truncamiento por max_tokens)
# Antes (buggy):
"extra_body": {"top_k": 64, "provider": {"order": provider_order, "allow_fallbacks": True}}

# Después (fix C2):
allow_fallbacks_raw = (os.environ.get("OPENROUTER_ALLOW_FALLBACKS") or "false").lower().strip()
allow_fallbacks = allow_fallbacks_raw in ("true", "1", "yes")
"extra_body": {
    "top_k": 64,
    "provider": {
        "order": provider_order,
        "allow_fallbacks": allow_fallbacks,
        "require_parameters": True,
    },
}

Hipótesis de causa principal: C2 es el bug más probable como causa raíz de la varianza 45/60/78 racks. Si tras el fix la varianza desaparece, queda confirmado. Si persiste, la variabilidad es estructural del modelo Vertex.


C3 — _clean_llm_json aplicaba regex agresivas a ciegas

Síntoma: En ocasiones, el cleanup de la respuesta LLM corrompía un JSON originalmente válido porque las regex diseñadas para reparar glitches Gemma 4 disparaban falsos positivos en claves de string que contenían :.

Causa: La función _clean_llm_json combinaba transformaciones conservadoras (strip markdown, normalizar comillas) con regex agresivas (reparar "key: sin comilla de cierre) en una sola pasada, sin posibilidad de diagnóstico diferencial.

Ejemplo de falso positivo:

{"text": "PLANTA M0", "x": 140, ": 700, "color": "blue"}

La regex ([{,]\s*)"([A-Za-z_][\w-]*): podía activarse sobre claves con valores string que contenían :.

Fix: Split en dos funciones con responsabilidades distintas:

FunciónComportamientoCuándo aplicar
_clean_llm_json_basicSolo transformaciones no destructivas: strip markdown, thinking blocks, slicing {…}, trailing commas, smart quotesSiempre, primera pasada
_clean_llm_json_aggressiveRegex de reparación Gemma 4 (con lookahead mejorado)Solo si json.loads falla sobre basic
_clean_llm_jsonCompat alias = basic + aggressivePara callers legacy (ollama, deepseek)

El driver openrouter_driver.py ahora aplica la cascada explícitamente: basic → json.loads → (si falla) aggressive → json.loads → (si falla) retry con seed+1

La regex agresiva se mejoró con lookahead para evitar falsos positivos:

# Antes:
re.sub(r'([{,]\s*)"([A-Za-z_][\w-]*):', r'\1"\2":', text)

# Después (con lookahead):
re.sub(r'([{,]\s*)"([A-Za-z_][A-Za-z0-9_-]*):(?=\s*[\d\-"\[{tfn])', r'\1"\2":', text)

Variables de entorno afectadas

VariableDefault anteriorDefault nuevoDescripción
OPENROUTER_ALLOW_FALLBACKSN/A (hardcoded True)falseControla si OpenRouter puede hacer fallback a otros providers
OPENROUTER_SEEDSin cambioSin cambioSemilla base; ahora se preserva correctamente entre retries

Plan de verificación

  1. CI verde (Backend + Frontend, modo B)
  2. Merge → Dokploy redeploya
  3. Lanzar 3-4 tests con misma config (seed 43, 2048, prompt original)
  4. Log debe mostrar served by Google consistente Y seed=43 en cada attempt
  5. Si varianza desaparece → C2 era la causa principal ✓
  6. Si varianza persiste → variabilidad estructural de Vertex, no hay más palancas SDK

Impacto en backward compatibility

  • _clean_llm_json se mantiene como alias de compatibilidad para callers existentes (ollama, deepseek). El comportamiento es idéntico al pre-s49 (basic + aggressive).
  • El cambio de allow_fallbacks a False sí puede impactar en entornos donde Vertex tiene saturación frecuente — en ese caso, activar OPENROUTER_ALLOW_FALLBACKS=true explícitamente.

Véase también

  • [[entity—blueprints—service—openrouter-driver]]
  • [[entity—blueprints—service—autoplan]]
  • [[entity—blueprints—model—aiprompt]]
  • [[entity—blueprints—endpoint—autoplan-import]]