Metadata-Version: 2.4
Name: micronnx
Version: 0.2.4.0
Summary: micronnx — runtime de inferencia puro NumPy para extracción de pesos, capas y activaciones de LLMs, CNNs y audio (Whisper). Fusión real de modelos (UFM/Unified Fusion Model): misma arquitectura vía promedio de pesos ponderado (más rápido que versiones anteriores), modo experimental para arquitecturas de texto distintas con verificación automática de pregunta/respuesta, y un puente geométrico SIN ENTRENAR (no multimodal real) entre modelos de visión y texto. Soporta GGUF (Q2_K-Q6_K), SafeTensors, HDF5/Keras y NPZ sin PyTorch ni TensorFlow.
License-Expression: MIT
Project-URL: Repository, https://github.com/tuusuario/micronnx
Keywords: llm,inference,numpy,gguf,safetensors,hdf5,mobilenet,activation-extraction,model-fusion,quantization,whisper,audio,speech-recognition,model-merging,model-soup,multimodal-bridge
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: pyfive
Provides-Extra: llm-qa
Requires-Dist: tokenizers>=0.15; extra == "llm-qa"
Dynamic: license-file

<p align="center">
  <img src="https://raw.githubusercontent.com/lmontanohernandez8-png/Micronnx/main/logo.png" width="600"/>
</p>

# micronnx

> Capa de extracción de pesos, capas y activaciones para sistemas de fusión de modelos.
> Parte del ecosistema **UFM** (Unified Fusion Model).

micronnx no es un framework de entrenamiento ni un motor de inferencia general.
Es una pieza de recolección: carga cualquier modelo desde cualquier formato, extrae sus pesos y activaciones capa a capa, y los expone en una estructura unificada lista para que UFM tome decisiones de fusión.

**Sin PyTorch. Sin TensorFlow. Solo NumPy.**

---

## Instalación

    pip install micronnx

---

## Empezar rápido — nx.load()

Si no sabes qué combinación de Loader + detect_schema_* + Runner
corresponde a tu archivo, no hace falta saberlo:

    import micronnx as nx

    runner, info = nx.load("modelo.safetensors")   # o .gguf / .h5 / .keras
    print(info)   # {'modality': 'text', 'architecture': 'causal-lm', 'schema_name': 'hf_llama'}

    # A partir de aquí, la API de siempre — nx.load() solo elige el
    # runner correcto, no cambia cómo se usa después.
    logits = runner.forward(input_ids)

Funciona igual para las tres modalidades:

    runner, info = nx.load("bert-base-uncased/model.safetensors")
    hidden = runner.forward(input_ids, attention_mask=mask)   # detecta BertRunner

    runner, info = nx.load("whisper-base/model.safetensors")
    hidden = runner.forward(mel_spectrogram)                  # detecta WhisperEncoderRunner

    runner, info = nx.load("resnet50/model.safetensors")
    logits = runner.forward(image)                            # detecta ResNetRunner, depth=50

Cualquier argumento extra se reenvía al runner final y sobrescribe lo
detectado — útil para forzar `max_seq`, `num_classes`, o cualquier otro
parámetro sin abandonar `nx.load()` por eso:

    runner, info = nx.load("modelo.gguf", max_seq=512)

**Lo que nx.load() NO adivina** (y por qué, honestamente):

  - **EfficientNet**: detecta que es EfficientNet (por la señal de
    Squeeze-and-Excitation, exclusiva de esa familia), pero la variante
    exacta (b0..b7) determina un factor de escalado de canales que no
    se puede leer con certeza de un tensor — se rechaza con un
    ValueError claro en vez de arriesgar la variante equivocada en
    silencio. Pásala explícita: `nx.load(path, variant="b0")`.
  - **MobileNetV2 vs V3**: se puede distinguir de EfficientNet y de
    MobileNetV1 con certeza, pero V2 y V3 solo difieren en qué bloques
    usan hardswish vs relu — un detalle que no queda grabado en ningún
    tensor. Se asume V2 (comportamiento por defecto de siempre) y se
    avisa con un `UserWarning` explicando cómo confirmar V3 a mano
    (`use_hardswish=[...]`).
  - **.npz**: usa `nx.NpzModelLoader` + `nx.detect_schema_npz`
    directamente — tienen su propio contrato (`model_key`) que no
    encaja bien en la firma de `nx.load()`.
  - Nada de esto es un límite de la librería en sí — cada uno de esos
    casos se puede instanciar a mano con el runner correcto (ver el
    resto de este README), `nx.load()` solo evita adivinar donde no
    hay suficiente certeza.

---

## Fusionar modelos (v0.2.3.0, ampliado en v0.2.4.0)

    import micronnx as nx

    modelo_a, _ = nx.load("modelo_a.safetensors")
    modelo_b, _ = nx.load("modelo_b.safetensors")

    # Antes de fusionar, comprobar compatibilidad (gratis, sin tocar pesos)
    check = nx.compatibility(modelo_a, modelo_b)
    print(f"{check.percent:.1f}% compatible")
    if check.risky:
        print(check.warning)

    # Misma arquitectura exacta -> fusión real (promedio de pesos).
    # weight_a (nuevo en v0.2.4.0): pondera un modelo más que el otro
    # en vez de forzar siempre 50/50 -- default 0.5 (igual que antes).
    nx.merge_same_architecture(modelo_a, modelo_b, "fusionado.npz")
    nx.merge_same_architecture(modelo_a, modelo_b, "fusionado_70_30.npz", weight_a=0.7)

    # Arquitecturas de TEXTO distintas -> "incompatibles" (antes se
    # llamaba "experimental" en v0.2.3.0 -- renombrado en v0.2.4.0
    # para dejar el nombre "experimental" al puente imagen-texto de
    # abajo, que es el que de verdad merece esa etiqueta). Huecos se
    # rellenan con valores reales del modelo que sí los tiene, nunca
    # con ceros. Solo texto -- ver el puente más abajo para imagen+texto.
    result = nx.merge_experimental(modelo_a, modelo_b, "fusionado_incompatibles.npz")
    print(f"{result.n_tensors_from_a_only} tensores solo de A, "
          f"{result.n_tensors_from_b_only} solo de B, "
          f"{result.n_tensors_cropped} recortados por forma distinta")

    # Verificación automática con pregunta real (requiere
    # pip install micronnx[llm-qa] para el tokenizador)
    from tokenizers import Tokenizer
    tok = Tokenizer.from_file("tokenizer.json")
    qa = nx.orchestrate_experimental_merge(
        modelo_a, modelo_b, tok, "¿Cuál es la capital de Francia?",
        "fusionado_verificado.npz",
    )
    if qa.success:
        print(qa.answer_merged)   # respuesta del modelo ya fusionado
    else:
        print("La fusión no dio una respuesta limpia tras varios intentos")

### Puente imagen-texto — nuevo en v0.2.4.0, EL VERDADERO experimental

⚠ Esto NO es un modelo multimodal real. Es una transformación
geométrica FIJA (sin entrenar, sin datos, sin gradientes) que conecta
un modelo de visión con uno de texto. No hay ninguna garantía de que
el resultado tenga sentido -- ver la sección UFM más abajo para el
porqué completo antes de usar esto para algo que importe.

    resnet, _ = nx.load("resnet50.safetensors")
    modelo_texto, _ = nx.load("modelo.safetensors")

    resultado = nx.bridge_image_to_text(
        resnet, modelo_texto, imagen, prompt_ids=[1, 2, 3], k=5,
    )
    print(resultado.warning)   # siempre presente, léelo
    logits = modelo_texto.forward(resultado.combined_input_ids)

Ver "Parte del ecosistema UFM" al final de este README para el detalle
completo de cada función, incluyendo qué se verificó y cómo.

---

## ¿Qué hace micronnx?

- Punto de entrada único, `nx.load(path)` — detecta formato, modalidad
  y arquitectura, y devuelve el runner correcto ya listo. Ver arriba.
- Carga de modelos: GGUF (Q2_K a Q6_K, Q8_K, Q8_0, Q8_1, IQ4_NL, BF16, F16, F32), SafeTensors, HDF5/Keras, NPY/NPZ — todos lazy, sin cargar nada hasta pedirlo
- Extracción de pesos: todos los tensores normalizados a float32, etiquetados con rol y capa
- Extracción de activaciones: capa a capa — embed, attn, ffn, residual, norm, pool
- Exportación unificada: uno o varios modelos a un solo .npz con índice completo (schema y hp incluidos)
- Carga desde .npz: NpzModelLoader detecta arquitectura automáticamente (incluyendo audio, desde esta versión) incluso en .npz de versiones antiguas sin schema_name
- Ops vectorizadas: RMSNorm, Attention GQA, RoPE, SwiGLU, GeGLU, Conv1D, Conv2D, GroupNorm y más — sin loops Python
- LLM decoder-only (LLaMA, Mistral, Gemma, Phi, ...) vía ModelRunner, encoder-decoder (T5, Flan-T5, mT5, UL2) vía T5Runner, y bidireccional (BERT, RoBERTa) vía BertRunner
- Sliding window real (Gemma 2/3, Mistral) — antes se detectaba en metadata y se ignoraba en el forward
- Visión: MobileNet (v1/v2/v3), ResNet (18/34/50/101/152), EfficientNet (B0-B7) y Vision Transformer (B/16 a H/14)
- Audio: Whisper (tiny a large), solo encoder — ver sección Audio
- Fusión de modelos: real (promedio de pesos ponderado) para misma arquitectura, "incompatibles" (con relleno de huecos) para arquitecturas de texto distintas, y un puente geométrico sin entrenar (NO multimodal real) entre visión y texto — ver sección Fusionar modelos

---

## Formatos soportados

GGUF (.gguf)
  Q2_K, Q3_K, Q4_0, Q4_1, Q4_K, Q5_0, Q5_1, Q5_K, Q6_K, Q8_K
  Q8_0, Q8_1, IQ4_NL, BF16, F16, F32
  No incluidos todavía: IQ4_XS, i-quants de codebook (IQ2_XXS/XS/S,
  IQ3_XXS/S, IQ1_S/M), TQ1_0/TQ2_0 — ver "Limitaciones conocidas"

SafeTensors (.safetensors)
  F32, F16, BF16 (corregido en v2), F64 convertido a F32 automaticamente
  I8, I16, I32, I64 sin conversion

HDF5 / Keras (.h5, .keras)
  float32, float64 convertido a float32, lazy loading

NumPy (.npy, .npz)
  float32, float16 convertido a float32, mmap lazy para .npz

---

## Arquitecturas detectadas automaticamente

GGUF:
  gguf_llama, gguf_gemma, gguf_gemma2, gguf_phi3
  gguf_falcon, gguf_falcon40b, gguf_gpt2, gguf_gpt_neox
  gguf_bloom, gguf_mpt, gguf_bert, gguf_mixtral
  gguf_qwen_moe, gguf_olmo2

HuggingFace SafeTensors:
  hf_llama, hf_gemma, hf_gemma2, hf_gemma3, hf_phi2, hf_phi3
  hf_falcon, hf_falcon40b, hf_gpt2, hf_gpt_neox
  hf_bloom, hf_mpt, hf_bert, hf_mixtral, hf_qwen_moe
  hf_chatglm, hf_cohere, hf_olmo2, hf_internlm2
  hf_baichuan, hf_stablelm, hf_minicpm, hf_xverse, hf_t5

  NOTA: hf_t5 corre de verdad con nx.T5Runner (encoder-decoder, ver
  seccion dedicada mas abajo) — en versiones anteriores el schema
  estaba declarado pero ModelRunner lo rechazaba con NotImplementedError.
  ModelRunner sigue sin aceptar hf_t5 (correcto: es decoder-only, T5 no
  encaja ahi); usa T5Runner para esta familia.

  gemma3 se detecta de forma diferenciada de gemma2 (patron de sliding
  window 5:1 vs 1:1) tanto desde config.json (detect_schema_hf) como
  desde los nombres de tensor sin config (detect_schema_safetensors,
  via la presencia de self_attn.q_norm/k_norm, exclusivos de Gemma3).

Cubre: LLaMA 1/2/3/3.1/3.2, Mistral, Qwen 1/2/2.5/3, SmolLM, Gemma 1/2/3,
Phi-2/3/3.5/4, Falcon 7B/40B, GPT-2, Starcoder2, GPT-NeoX, Pythia, BLOOM,
MPT, BERT, RoBERTa, DeBERTa, Mixtral, Qwen2-MoE, DeepSeek V2/V3,
ChatGLM4, Cohere Command-R, Aya, OLMo2, InternLM2, Baichuan2,
StableLM, MiniCPM, XVERSE, T5, mT5, umT5, T5v1.1, Flan-T5, UL2,
MobileNetV1, MobileNetV2, MobileNetV3, ResNet18/34/50/101/152,
EfficientNet B0-B7 (V1), ViT-B/16, ViT-B/32, ViT-L/16, ViT-L/32, ViT-H/14

  NOTA: BART NO esta soportado, pese a ser tambien encoder-decoder.
  En versiones anteriores "bart" apuntaba por error al mismo schema que
  T5 (hf_t5), pese a que BART usa posiciones absolutas aprendidas + GELU,
  no bias posicional relativo por bucket logaritmico como T5 -- son
  matematicas de forward distintas. Ese mapeo se retiro; un checkpoint
  BART hoy cae en el fallback hf_llama y falla con KeyError (ruidoso),
  en vez de correr en silencio con resultados incorrectos como antes.
  Soporte real de BART queda pendiente.

---

## Uso rapido

    import micronnx as nx

    loader     = nx.GGUFLoader("model.gguf")
    schema, hp = nx.detect_schema_gguf("model.gguf")
    runner     = nx.ModelRunner(loader, schema, hp)
    logits     = runner.forward(input_ids)

---

## T5 / Flan-T5 / mT5 / UL2 — encoder-decoder

T5 es una arquitectura distinta a todo lo demas en este README: dos
torres (encoder + decoder) con cross-attention entre ambas, y bias
posicional relativo por bucket logaritmico en vez de RoPE. Por eso usa
un runner propio, nx.T5Runner, en vez de nx.ModelRunner.

    import numpy as np
    import micronnx as nx

    loader     = nx.SafeTensorsLoader("flan-t5-small/model.safetensors")
    schema, hp = nx.detect_schema_hf("flan-t5-small/config.json")
    t5         = nx.T5Runner(loader, schema, hp, max_seq=512)

    # El encoder se corre UNA VEZ por secuencia de entrada
    encoder_ids = np.array([[...]], dtype=np.int64)
    t5.encode(encoder_ids)

    # El decoder corre paso a paso, con KV cache incremental
    # (igual de valido pasar el prefijo completo de una vez que ir
    # token a token -- ambos caminos dan el mismo resultado numerico)
    decoder_ids = np.array([[0]], dtype=np.int64)   # decoder_start_token_id
    logits      = t5.forward(decoder_ids)
    next_token  = int(logits[0, -1].argmax())

    # Para una nueva secuencia de entrada, limpiar el estado primero
    t5.reset()

Cubre T5 clasico (ReLU, sin puerta) y T5v1.1/Flan-T5/UL2 (GeGLU,
wi_0/wi_1) automaticamente, detectado desde config.json
(feed_forward_proj: "relu" o "gated-gelu") o inferido con el default
"relu" si el config no trae ese campo.

---

## Visión — ResNet, EfficientNet, ViT

    import numpy as np
    import micronnx as nx

    image = np.random.uniform(0, 1, (224, 224, 3)).astype(np.float32)

    # ResNet — 18, 34, 50, 101 o 152 capas
    loader = nx.SafeTensorsLoader("resnet50/model.safetensors")
    resnet = nx.ResNetRunner(loader, depth=50, num_classes=1000)
    logits = resnet.forward(image)   # SIN softmax, igual que torchvision

    # EfficientNet — b0 a b7 (V1; V2 no soportado, ver limitaciones)
    loader = nx.SafeTensorsLoader("efficientnet_b0/model.safetensors")
    effnet = nx.EfficientNetRunner(loader, variant="b0", num_classes=1000)
    logits = effnet.forward(image)

    # Vision Transformer — b_16, b_32, l_16, l_32, h_14
    loader = nx.SafeTensorsLoader("vit_b_16/model.safetensors")
    vit    = nx.ViTRunner(loader, variant="b_16", num_classes=1000, image_size=224)
    logits = vit.forward(image)

    # Con extraccion de activaciones (misma convencion on_activation
    # que ModelRunner/CNNRunner)
    acts = {}
    logits = resnet.forward(image, on_activation=lambda k, t: acts.__setitem__(k, t))
    print(acts.keys())
    # stem_conv, stem_pool, layer1.0_out, layer1.1_out, ..., pooled, logits

Los tres runners usan nombres de tensor y estructura de torchvision
(no el puerto de HuggingFace, que usa nomenclatura distinta). ViTRunner
requiere que la imagen coincida exactamente con image_size (no
interpola pos_embedding para tamaños arbitrarios, a diferencia de
torchvision).

---

## Audio — Whisper (solo encoder, ver limitaciones)

    import numpy as np
    import micronnx as nx

    # mel_spectrogram: (80, 3000) o (3000, 80) — log-mel spectrogram ya
    # calculado (p.ej. con WhisperFeatureExtractor de HuggingFace). Este
    # runner NO calcula el spectrogram desde audio crudo — ver limitaciones.
    mel = np.load("mel_spectrogram.npy")

    loader  = nx.SafeTensorsLoader("whisper-base/model.safetensors")
    encoder = nx.WhisperEncoderRunner(
        loader,
        d_model=512, encoder_layers=6, encoder_attention_heads=8,
        num_mel_bins=80, prefix="model.encoder.",   # whisper-base real
    )
    hidden = encoder.forward(mel)   # (1500, 512) — last_hidden_state

    # Con extraccion de activaciones (mismo patron on_activation que
    # ModelRunner/ResNetRunner — reduce="temporal" colapsa (T,D)->(D,))
    ext    = nx.ActivationExtractor(encoder, hooks=["post_attn"], reduce="temporal")
    hidden = ext.run_whisper(mel)
    layer3 = ext.get("post_attn_3")   # (512,)

Tamaños reales de Whisper (d_model / encoder_layers / encoder_attention_heads):
tiny 384/4/6, base 512/6/8, small 768/12/12, medium 1024/24/16,
large 1280/32/20 — mismo num_mel_bins=80 y misma arquitectura de
encoder en los cinco, solo cambia el tamaño. Solo el ENCODER está
implementado (no el decoder, que genera texto) — la salida de
WhisperEncoderRunner es la representación de audio en sí, útil para
extracción de embeddings/activaciones de audio y comparación de
representaciones, no para transcripción directa. Ver "Limitaciones
conocidas" para el porqué de ese corte y qué falta exactamente.

---

## Exportar modelos a .npz

    import micronnx as nx

    # Un solo modelo
    nx.export_to_npz("model.gguf", "model.npz")

    # Varios modelos, un .npz por cada uno
    nx.export_to_npz(
        ["model.gguf", "model.safetensors", "mobilenet.h5"],
        "outputs/"
    )

    # Varios modelos en un solo .npz fusionado
    nx.export_to_npz(
        ["model.gguf", "model.safetensors", "mobilenet.h5"],
        "outputs/merged.npz",
        merge=True
    )

    # Con string separado por comas
    nx.export_to_npz("model.gguf, model.safetensors", "outputs/", merge=False)

---

## Inspeccionar un .npz

    import micronnx as nx

    nx.inspect_npz("outputs/merged.npz")
    # Merged  : 2 modelos
    # Total   : 269,030,016 params | 513.14 MB float16
    # [SmolLM2-135M]  272 tensores | 30 capas | 256.57 MB | schema: gguf_llama
    # [model]         272 tensores | 30 capas | 256.57 MB | schema: hf_llama

    # Leer el indice sin cargar ningun tensor
    idx = nx.load_index("outputs/merged.npz")
    print(idx["n_models"])
    print(idx["total_params"])
    print(idx["models"].keys())

    # Schema e hiperparametros de cada modelo
    for name, info in idx["models"].items():
        print(name, info["schema_name"], info["hp"])

---

## Cargar desde .npz sin archivo original

    import micronnx as nx

    # Ver que modelos hay
    models = nx.list_models("outputs/merged.npz")
    for name, info in models.items():
        print(name, info["schema_name"], info["has_hp"], info["has_activations"])

    # Cargar directamente — detecta arquitectura automaticamente
    loader     = nx.NpzModelLoader("outputs/merged.npz", "SmolLM2-135M-Instruct-Q4_K_M")
    schema, hp = nx.detect_schema_npz("outputs/merged.npz", "SmolLM2-135M-Instruct-Q4_K_M")
    runner     = nx.ModelRunner(loader, schema, hp, max_seq=512)

    # Si el .npz no tiene schema_name (version antigua),
    # se detecta automaticamente desde los nombres de tensores sin el archivo original

---

## Extraer activaciones — LLM

    import numpy as np
    import micronnx as nx

    # Desde archivo original
    loader     = nx.GGUFLoader("SmolLM2-135M-Instruct-Q4_K_M.gguf")
    schema, hp = nx.detect_schema_gguf("SmolLM2-135M-Instruct-Q4_K_M.gguf")
    runner     = nx.ModelRunner(loader, schema, hp, max_seq=512)

    # O desde .npz sin archivo original
    loader     = nx.NpzModelLoader("outputs/merged.npz", "SmolLM2-135M-Instruct-Q4_K_M")
    schema, hp = nx.detect_schema_npz("outputs/merged.npz", "SmolLM2-135M-Instruct-Q4_K_M")
    runner     = nx.ModelRunner(loader, schema, hp, max_seq=512)

    # Todas las activaciones
    ext = nx.ActivationExtractor(runner)
    ext.run(np.array([[1, 2, 3, 4, 5]], dtype=np.int64))
    print(ext.keys())
    # embed, attn_norm_0, post_attn_0, residual_attn_0,
    # ffn_norm_0, post_ffn_0, residual_ffn_0, ..., final_norm

    # One-shot sin instanciar
    logits, acts = nx.ActivationExtractor.extract(
        runner,
        np.array([[1, 2, 3]], dtype=np.int64)
    )

    # Solo algunos hooks
    ext = nx.ActivationExtractor(runner, hooks=["post_attn", "residual_ffn"])
    ext.run(np.array([[1, 2, 3, 4, 5]], dtype=np.int64))

    # Reducir la dimension de secuencia
    ext = nx.ActivationExtractor(runner, reduce="last")   # ultimo token
    ext = nx.ActivationExtractor(runner, reduce="mean")   # media de tokens

    # Solo capas pares
    ext = nx.ActivationExtractor(runner, layer_fn=lambda i: i % 2 == 0)

    # Solo capas especificas por indice
    ext = nx.ActivationExtractor(runner, layer_fn=[0, 5, 11, 23])

    # Acceso a activaciones
    hidden = ext.get("final_norm")       # KeyError claro si no fue capturado
    attn5  = ext.get("post_attn_5")
    for key, tensor in ext.items():
        print(key, tensor.shape)

---

## Extraer activaciones — BERT

    import numpy as np
    import micronnx as nx

    loader     = nx.SafeTensorsLoader("bert-base-uncased/model.safetensors")
    schema, hp = nx.detect_schema_safetensors("bert-base-uncased/model.safetensors")
    runner     = nx.BertRunner(loader, schema, hp)

    ext    = nx.ActivationExtractor(runner, hooks=["post_attn", "final_norm"])
    hidden = ext.run_bert(
        np.array([[101, 2054, 2003, 102]], dtype=np.int64),
        attention_mask=np.ones((1, 4), dtype=np.int64)
    )

    # One-shot para BERT
    hidden, acts = nx.ActivationExtractor.extract_bert(runner, input_ids)
    cls_vector   = acts["final_norm"][0, 0]   # token [CLS]

---

## Extraer activaciones — CNN (MobileNet)

    import numpy as np
    import micronnx as nx

    raw    = nx.H5Loader("mobilenet_1_0_224_tf.h5")
    mapped = nx.map_tensors(dict.fromkeys(raw.tensor_names), fmt="h5")
    loader = nx.CanonicalLoader(raw, mapped)
    runner = nx.CNNRunner(loader, n_blocks=13)

    # Imagen 224x224x3 normalizada en [-1, 1]
    image = np.random.uniform(-1, 1, (224, 224, 3)).astype(np.float32)

    # Forward directo
    probs = runner.forward(image)
    print(f"clase: {probs.argmax()}, confianza: {probs.max():.3f}")

    # Con extraccion de activaciones
    ext   = nx.CNNActivationExtractor(runner, reduce="spatial")
    probs = ext.run(image)
    print(ext.keys())
    # stem, block_0_dw, block_0_pw, ..., block_12_pw, pooled

    # One-shot
    probs, acts = nx.CNNActivationExtractor.extract(runner, image)
    feat = acts["block_5_pw"]   # (C,) con reduce="spatial"

    # MobileNetV2 / MobileNetV3
    runner = nx.InvertedResidualRunner(loader, n_blocks=17)
    probs  = runner.forward(image)

---

## Extraer activaciones — Audio (Whisper)

    import numpy as np
    import micronnx as nx

    loader  = nx.SafeTensorsLoader("whisper-base/model.safetensors")
    encoder = nx.WhisperEncoderRunner(loader, prefix="model.encoder.")

    mel = np.load("mel_spectrogram.npy")   # (80, 3000)

    # A diferencia de CNNRunner, WhisperEncoderRunner SÍ acepta
    # on_activation nativamente — no hace falta un extractor aparte con
    # el forward duplicado (no hay equivalente a CNNActivationExtractor
    # para audio porque no hace falta uno).
    ext    = nx.ActivationExtractor(encoder, hooks=["conv1", "conv2", "post_attn"], reduce="temporal")
    hidden = ext.run_whisper(mel)
    print(ext.keys())
    # conv1, conv2, post_attn_0, ..., post_attn_5

    # One-shot
    hidden, acts = nx.ActivationExtractor.extract_whisper(encoder, mel)
    stem_out = acts["conv2"]   # (1500, 512) sin reduce

---

## Guardar y leer activaciones en el .npz

    import numpy as np
    import micronnx as nx

    merged = nx.export_to_npz(
        ["SmolLM2-135M-Instruct-Q4_K_M.gguf", "model.safetensors"],
        "outputs/merged.npz",
        merge=True
    )

    loader     = nx.NpzModelLoader(merged, "SmolLM2-135M-Instruct-Q4_K_M")
    schema, hp = nx.detect_schema_npz(merged, "SmolLM2-135M-Instruct-Q4_K_M")
    runner     = nx.ModelRunner(loader, schema, hp)

    ext = nx.ActivationExtractor(runner, reduce="last")
    ext.run(np.array([[1, 2, 3]], dtype=np.int64))

    nx.save_activations(merged, ext.activations, model_key="SmolLM2-135M-Instruct-Q4_K_M")

    # Leer despues sin recargar el modelo
    acts = nx.load_activations(merged, model_key="SmolLM2-135M-Instruct-Q4_K_M")
    print(acts["final_norm"].shape)

---

## TensorRegistry — hasta 3 modelos lazy simultaneos

    import micronnx as nx

    # Registrar modelos — solo indexa nombres, no carga datos
    nx.registry.register("llama3",   nx.GGUFLoader("llama3.gguf"),         fmt="gguf")
    nx.registry.register("mistral",  nx.SafeTensorsLoader("mistral.st"),   fmt="safetensors")
    nx.registry.register("mobilenet",nx.H5Loader("mobilenet.h5"),          fmt="h5")

    # Consultar sin cargar nada — O(1)
    nx.registry.has("llama3", "layers.5.attn.q.weight")   # True / False
    nx.registry.list("llama3")                             # lista de canonicos

    # Cargar un tensor — solo aqui se lee del disco
    t = nx.registry.get("llama3", "layers.5.attn.q.weight")

    # Al registrar un 4to modelo, el mas antiguo se expulsa automaticamente (LRU)
    nx.registry.release("mistral")   # liberar manualmente
    nx.registry.stats()              # {"models": [...], "slots_used": 2, ...}

    # O instanciar un registry propio
    reg = nx.TensorRegistry()
    reg.register("modelo_a", loader_a)
    reg.register("modelo_b", loader_b)

---

## Loaders directos

    import micronnx as nx

    # GGUF — lazy, mmap, dequantiza bajo demanda
    loader = nx.GGUFLoader("model.gguf")
    print(loader.tensor_names[:5])
    w = loader.load("token_embd.weight")
    loader.close()

    # SafeTensors — lazy, mmap, BF16/F64 correctos
    loader = nx.SafeTensorsLoader("model.safetensors")
    print(loader.shape("model.embed_tokens.weight"))    # sin cargar
    print(loader.dtype("model.embed_tokens.weight"))    # sin cargar
    w = loader.load("model.embed_tokens.weight")

    # Cargar solo tensores de atencion (ahorra 30-70% de RAM)
    weights = loader.load_all(filter_fn=lambda n: "attn" in n)

    # Exportar sin pico de RAM — un tensor a la vez
    for name, arr in loader.stream_load(keep_float16=True):
        pass   # procesar arr y liberar

    loader.close()

    # HDF5 / Keras — lazy, sin cargar hasta .load()
    loader = nx.H5Loader("mobilenet.h5")
    w = loader.load("conv1/kernel:0")
    attrs = loader.attributes("conv1")   # metadata de la capa Keras
    loader.close()

    # NPY / NPZ — mmap lazy para .npz
    loader = nx.NpyLoader("weights.npz")
    w = loader.load("layer_0")

    # Directorio de .npy (estilo JAX/Flax)
    loader = nx.NpyLoader("checkpoints/")
    w = loader.load("encoder/layers/0/attn/q")

    # Desde .npz merged sin archivo original
    loader = nx.NpzModelLoader("outputs/merged.npz", "model")
    w = loader.load("model.embed_tokens.weight")
    loader.close()

---

## Detectar schema y arquitectura

    import micronnx as nx

    # Desde archivo original
    schema, hp = nx.detect_schema_gguf("model.gguf")
    print(schema)
    # gguf_llama / gguf_gemma2 / gguf_phi3 / gguf_mixtral / gguf_qwen_moe ...
    print(hp)
    # {"n_layers": 30, "n_heads": 9, "n_kv_heads": 3, "n_embd": 576, "vocab_size": 49152}

    schema, hp = nx.detect_schema_safetensors("model.safetensors")
    schema, hp = nx.detect_schema_hf("model.safetensors")   # alias

    # Desde .npz sin archivo original
    schema, hp = nx.detect_schema_npz("outputs/merged.npz", "SmolLM2-135M-Instruct-Q4_K_M")

    # Ver todos los schemas disponibles
    print(list(nx.SCHEMAS.keys()))
    # gguf_llama, gguf_gemma, gguf_gemma2, gguf_phi3, gguf_falcon,
    # gguf_mixtral, gguf_qwen_moe, hf_llama, hf_gemma2, hf_bert ...

---

## Mapeo canonico de tensores

    import micronnx as nx

    # Convertir nombres originales a nombres canonicos
    loader = nx.GGUFLoader("model.gguf")
    mapped = nx.map_tensors(dict.fromkeys(loader.tensor_names), fmt="gguf")

    # Ver tensores sin mapear
    unmapped = nx.find_unmapped(dict.fromkeys(loader.tensor_names), fmt="gguf", mapped=mapped)

    # Resolver embeddings atados (embed <-> head)
    mapped = nx.resolve_tied_embeddings(mapped)

    # Detectar formato automaticamente
    fmt = nx.detect_format(dict.fromkeys(loader.tensor_names))

    # CanonicalLoader: traduce nombres canonicos a originales automaticamente
    canonical = nx.CanonicalLoader(loader, mapped)
    w = canonical.load("layers.0.attn.q.weight")   # nombre canonico
    print("layers.0.attn.q.weight" in canonical)   # True

---

## Ops NumPy directas

    import numpy as np
    import micronnx as nx

    x = np.random.randn(1, 16, 576).astype(np.float32)
    w = np.ones(576, dtype=np.float32)

    # Normalizacion
    x = nx.rmsnorm(x, w)
    x = nx.layernorm(x, w, w)
    x = nx.batchnorm(x, gamma, beta, mean, var)
    x = nx.group_norm(x, num_groups=8, weight=w)
    x = nx.instance_norm(x)

    # Activaciones
    x = nx.gelu(x)
    x = nx.quick_gelu(x)       # CLIP, ViT
    x = nx.silu(x)             # LLaMA, Mistral
    x = nx.mish(x)             # YOLOv4
    x = nx.hardswish(x)        # MobileNetV3
    x = nx.leaky_relu(x, 0.01) # GANs, YOLO
    x = nx.prelu(x, weight)    # ResNet
    x = nx.elu(x)

    # FFN
    out = nx.swiglu(x, gate_w, up_w, down_w)          # LLaMA, Mistral, Qwen
    out = nx.swiglu_fused(x, gate_up_w, down_w)       # Phi-3, InternLM2
    out = nx.geglu(x, gate_w, up_w, down_w)           # Gemma
    out = nx.geglu_fused(x, gate_up_w, down_w)        # T5v1.1, Flan-T5
    out = nx.ffn_gelu(x, up_w, down_w)                # GPT-2, BLOOM
    out = nx.ffn_relu(x, up_w, down_w)                # T5 original

    # Atencion GQA (cubre MHA y MQA como casos especiales)
    q   = np.random.randn(1, 4, 9, 64).astype(np.float32)
    k   = np.random.randn(1, 4, 3, 64).astype(np.float32)
    v   = np.random.randn(1, 4, 3, 64).astype(np.float32)
    out = nx.attention(q, k, v, n_heads=9, n_kv_heads=3)

    # SDPA directa sin reshape de heads (ViT, CLIP)
    out = nx.scaled_dot_product(q, k, v, mask=None)

    # RoPE — convencion LLaMA correcta
    x   = np.random.randn(1, 4, 8, 64).astype(np.float32)
    out = nx.rope(x, pos=0)
    out = nx.rope(x, positions=np.array([0, 1, 2, 3]))

    # CNN
    img  = np.random.randn(112, 112, 32).astype(np.float32)
    filt = np.random.randn(64, 32, 3, 3).astype(np.float32)   # formato PyTorch (C_out,C_in,kH,kW)
    out  = nx.conv2d(img, filt, stride=1, padding=1)
    out  = nx.depthwise_conv2d(img, dw_weight, stride=2)
    out  = nx.global_avg_pool(out)
    out  = nx.avg_pool2d(out, kernel_size=2)
    out  = nx.max_pool2d(out, size=2, stride=2)
    out  = nx.adaptive_avg_pool(out, output_size=(7, 7))
    out  = nx.upsample_nearest(out, scale_factor=2)

    # Audio — conv1d, mismo formato de peso que conv2d pero un solo eje espacial
    mel   = np.random.randn(3000, 80).astype(np.float32)      # (T, mel_bins)
    filt1 = np.random.randn(512, 80, 3).astype(np.float32)    # (C_out, C_in, K)
    out   = nx.conv1d(mel, filt1, stride=1, padding=1)        # (3000, 512)

---

## API completa

Conveniencia (nuevo)
  nx.load(path, **overrides) -> (runner, info)
    Detecta formato + modalidad + arquitectura y devuelve el runner
    listo. Ver "Empezar rápido" al inicio de este README para el
    detalle de qué infiere con certeza y qué no.

Fusión y compatibilidad (v0.2.3.0, ampliado en v0.2.4.0)
  nx.compatibility(runner_a, runner_b, threshold=0.6) -> CompatibilityResult
    .score (0-1) .percent .risky .reason .warning .details
    Cero bytes de peso tocados -- solo metadata (hp, schema, nombres
    de tensor). Modalidad distinta -> 0%. Mismo tipo de runner distinto
    -> 5%. Mismo tipo -> score compuesto (70% shapes/hp, 30% familia).
  nx.merge_same_architecture(runner_a, runner_b, output_path, min_compatibility=0.9, weight_a=0.5) -> MergeResult
    Promedio de pesos PONDERADO, streaming. weight_a nuevo en v0.2.4.0
    (default 0.5 = comportamiento idéntico a versiones anteriores).
    Exige >= min_compatibility antes de intentarlo. Produce un .npz
    cargable con NpzModelLoader. ~2.2x más rápido que en v0.2.3.0
    (medido, ver sección UFM para el detalle del perfilado).
  nx.merge_experimental(runner_a, runner_b, output_path) -> MergeResult
    "Incompatibles" (arquitecturas de texto distintas -- ver sección
    UFM para por qué se renombró de "experimental" a este nombre en
    v0.2.4.0). Solo texto. Huecos rellenados con valores reales
    (nunca ceros); formas distintas se recortan al tamaño menor.
    .n_tensors_from_a_only .n_tensors_from_b_only .n_tensors_cropped
    .cropped_tensor_names
  nx.bridge_image_to_text(vision_runner, text_runner, image, prompt_ids, k=5, seed=0) -> ImageTextBridgeResult
    Nuevo en v0.2.4.0. EL verdadero experimental: puente geométrico
    SIN ENTRENAR entre visión y texto (NO es un modelo multimodal
    real -- ver sección UFM y el docstring de bridge.py antes de
    usarlo). .pseudo_token_ids .combined_input_ids .projection_seed
    .warning (siempre presente, léelo).
  nx.generate(runner, tokenizer, prompt, max_new_tokens=64) -> str
    Greedy decoding, reutiliza el KV-cache de ModelRunner. Requiere
    tokenizer de la librería `tokenizers` (pip install micronnx[llm-qa]).
  nx.looks_corrupted(text, min_repeat=4) -> bool
    Detecta rachas de 4+ caracteres no alfanuméricos repetidos seguidos.
  nx.orchestrate_experimental_merge(runner_a, runner_b, tokenizer, question, output_path, max_attempts=3) -> ExperimentalMergeQAResult
    .success .answer_a .answer_b .answer_merged .attempts .corrupted_attempts
    Pregunta antes/después de fusionar, reintenta si detecta corrupción.
  nx.TokenizerUnavailable
    Excepción con mensaje accionable si falta la librería `tokenizers`.

Loaders
  nx.GGUFLoader(path)                       lazy, mmap, dequant bajo demanda
  nx.SafeTensorsLoader(path)                lazy, mmap, BF16/F64 correctos
  nx.H5Loader(path)                         lazy, float64 convertido
  nx.NpyLoader(path)                        lazy mmap para .npz, dir para .npy
  nx.NpzModelLoader(npz_path, model_key)    desde .npz sin archivo original

Deteccion de schema
  nx.detect_schema_gguf(path)               (schema_name, hp)
  nx.detect_schema_safetensors(path)        (schema_name, hp)
  nx.detect_schema_hf(path)                 (schema_name, hp)
  nx.detect_schema_npz(npz_path, key)       (schema_name, hp) desde .npz
  nx.detect_schema_from_tensors(names, ...) desde lista de nombres de tensores
  nx.list_models(npz_path)                  metadata de todos los modelos
  nx.SCHEMAS                                dict con todos los schemas

TensorRegistry
  nx.registry                               instancia global (hasta 3 modelos LRU)
  nx.TensorRegistry()                       instancia propia
  reg.register(name, loader, fmt)           indexa sin cargar datos
  reg.get(name, canonical)                  carga aqui — unico punto de I/O
  reg.has(name, canonical)                  O(1) sin cargar nada
  reg.list(name)                            lista de canonicos disponibles
  reg.release(name)                         liberar slot manualmente
  reg.stats()                               info de slots usados

Runtime LLM
  nx.ModelRunner(loader, schema_name, hp, max_seq=2048)      decoder-only
  nx.BertRunner(loader, schema_name, hp)                     bidireccional (BERT/RoBERTa)
    runner.forward(input_ids, token_type_ids=None, attention_mask=None)
  nx.T5Runner(loader, schema_name, hp, max_seq=2048)         encoder-decoder (T5/Flan-T5/mT5/UL2)
    t5.encode(encoder_input_ids)                             una vez por secuencia
    t5.forward(decoder_input_ids)                            por paso, con KV cache
    t5.reset()                                                antes de una nueva secuencia
  nx.ActivationExtractor(runner, hooks=None, reduce=None, layer_fn=None, keep_copy=True)
    hooks:    ["embed","post_attn","residual_ffn","final_norm",...]
    reduce:   None | "last" | "mean" | "spatial" | "temporal"
              ("spatial" es para CNN, (H,W,C)->(C,); "temporal" es
              para audio, (T,D)->(D,) — ver Runtime Audio abajo)
    layer_fn: callable(i)->bool | list[int]
  nx.ActivationExtractor.extract(runner, input_ids, hooks, reduce)
  nx.ActivationExtractor.extract_bert(runner, input_ids, ...)
  nx.ActivationExtractor.extract_t5(runner, encoder_ids, decoder_ids, ...)
  nx.ActivationExtractor.extract_whisper(runner, mel_spectrogram, ...)
  nx.RoPECache / nx.KVCache / nx.WeightCache

Runtime CNN
  nx.CNNRunner(loader, n_blocks=13)                  MobileNetV1
  nx.InvertedResidualRunner(loader, n_blocks=17)     MobileNetV2 / V3
  nx.ResNetRunner(loader, depth=50, num_classes=1000)          18/34/50/101/152
  nx.EfficientNetRunner(loader, variant="b0", num_classes=1000) b0-b7 (V1)
  nx.ViTRunner(loader, variant="b_16", num_classes=1000, image_size=224)
  nx.CNNActivationExtractor(runner, reduce="spatial")
  nx.CNNActivationExtractor.extract(runner, image)
  nx.CanonicalLoader(raw_loader, mapped)

Runtime Audio
  nx.WhisperEncoderRunner(loader, d_model=512, encoder_layers=6,
                           encoder_attention_heads=8, num_mel_bins=80,
                           max_source_positions=1500, prefix="")
    encoder.forward(mel_spectrogram, on_activation=None) -> (1500, d_model)
    tamaños reales: tiny 384/4/6, base 512/6/8, small 768/12/12,
                     medium 1024/24/16, large 1280/32/20
  nx.sinusoids(length, channels, max_timescale=10000.0)
    positional embedding fijo — expuesto por si se necesita fuera del
    runner (comparar contra embed_positions.weight de un checkpoint, etc.)

Canonico
  nx.map_tensors(tensors, fmt, strict=False)
  nx.find_unmapped(tensors, fmt, mapped)
  nx.resolve_tied_embeddings(mapped)
  nx.detect_format(tensors)
  nx.CanonicalTensor

Exportador
  nx.export_to_npz(src, dst, fmt=None, merge=False, verbose=True)
  nx.load_index(path)
  nx.inspect_npz(path, n=20)
  nx.save_activations(path, acts, model_key=None)
  nx.load_activations(path, model_key=None)

Ops — normalizacion
  nx.rmsnorm(x, weight, eps=1e-6)
  nx.layernorm(x, weight, bias=None, eps=1e-5)
  nx.batchnorm(x, gamma, beta, mean, var, eps=1e-3)
  nx.group_norm(x, num_groups, weight=None, bias=None)
  nx.instance_norm(x, weight=None, bias=None)

Ops — activaciones
  nx.softmax(x, axis=-1)
  nx.sigmoid(x)
  nx.relu(x)
  nx.leaky_relu(x, negative_slope=0.01)
  nx.elu(x, alpha=1.0)
  nx.prelu(x, weight)
  nx.gelu(x)
  nx.quick_gelu(x)
  nx.silu(x)
  nx.mish(x)
  nx.hardswish(x)

Ops — lineales
  nx.linear(x, weight, bias=None)
  nx.embedding(idx, weight)

Ops — FFN
  nx.swiglu(x, gate_w, up_w, down_w)
  nx.swiglu_fused(x, gate_up_w, down_w)
  nx.geglu(x, gate_w, up_w, down_w)
  nx.geglu_fused(x, gate_up_w, down_w)
  nx.ffn_gelu(x, up_w, down_w)
  nx.ffn_relu(x, up_w, down_w)

Ops — atencion
  nx.attention(q, k, v, n_heads, n_kv_heads, mask=None, scale=None)
    scale: None usa 1/sqrt(head_dim) (default, sin cambios); T5 pasa
    scale=1.0 (no escala, lo compensa via inicializacion de pesos)
  nx.scaled_dot_product(q, k, v, mask=None)
  nx.rope(x, pos=None, base=10000.0, positions=None)
  nx.t5_relative_position_bucket(relative_position, bidirectional=True, num_buckets=32, max_distance=128)
  nx.t5_compute_position_bias(rel_bias_table, q_len, k_len, bidirectional=True, ...)

Ops — CNN / Audio
  nx.conv1d(x, weight, bias=None, stride=1, padding=0)      audio — ver nota abajo
  nx.conv2d(x, weight, bias=None, stride=1, padding=0)
  nx.depthwise_conv2d(x, weight, stride=1, padding=1)
  nx.global_avg_pool(x)
  nx.avg_pool2d(x, kernel_size=2, stride=None, padding=0)
  nx.max_pool2d(x, size=2, stride=2)
  nx.adaptive_avg_pool(x, output_size)
  nx.upsample_nearest(x, scale_factor=2)

---

## Correcciones v2

BF16 en SafeTensors — resultado silenciosamente incorrecto en v1.
  v1 convertia el valor numerico uint16 a uint32 antes de shiftear,
  lo cual es matematicamente incorrecto. v2 copia los bytes directamente
  a los bytes altos del float32, que es la definicion de BF16.
  Afectaba a: Mistral, LLaMA-3, Gemma, Phi y cualquier modelo HF en BF16.

Q8_1 en GGUF — scale ignorado en v1.
  v1 trataba los bytes int8 como float32 directos sin aplicar el scale d.
  v2 extrae d correctamente y devuelve d * qs.

RoPE — convencion incorrecta en v1.
  La segunda mitad usaba x1*sin + x2*cos en lugar de la convencion
  LLaMA canonica [-x2, x1]. Corregido en v2.

SwiGLU — usaba sigmoid(gate) en lugar de silu(gate).
  Silenciosamente incorrecto. silu(x) = x * sigmoid(x), no sigmoid(x).

conv2d / depthwise_conv2d — formato de pesos Keras vs PyTorch.
  v1 asumia (kH, kW, C_in, C_out). v2 usa formato PyTorch (C_out, C_in, kH, kW).

H5Loader — cargaba todos los arrays en el constructor (eager).
  Para un MobileNet eran ~100MB en RAM antes de pedir nada. v2 es lazy.

NpyLoader — cargaba el .npz completo en RAM en el constructor.
  v2 usa mmap_mode="r" — los arrays se leen del disco solo cuando se piden.

Exporter: se corrige defaul del archivo shora detcta mejor todo

---

## Correcciones y novedades v3

forward.py
  hf_t5 estaba declarado en SCHEMAS pero ModelRunner lo rechazaba con
  NotImplementedError ("requiere encoder+decoder"). Ahora nx.T5Runner
  lo ejecuta de verdad: encoder bidireccional + decoder con self-attn
  causal y cross-attn, bias posicional relativo por bucket logaritmico
  (verificado con 172 casos contra una traduccion literal del algoritmo
  de HuggingFace, y contra una implementacion de referencia en NumPy
  puro independiente del propio codigo de produccion). geglu_fused y
  ffn_relu de ops.py, que ya existian pero no estaban conectados al
  dispatcher de _ffn_layer, ahora se usan (T5 clasico usa un
  ffn_type nuevo, "gelu_no_gate", que es ReLU real sin puerta; T5v1.1/
  Flan-T5 reusan el "geglu" ya existente).

  sliding_window se detectaba desde hace tiempo en detect_schema_gguf/
  detect_schema_hf pero nunca se usaba en el forward -- Gemma 2/3 y
  Mistral corrian con atencion causal completa en silencio. Ahora
  _make_causal_mask acepta un ancho de ventana y ModelRunner.forward
  alterna capas locales/globales segun el patron real de cada
  arquitectura (Gemma 2: 1:1, Gemma 3: 5:1, Mistral: fijo en todas).

  Bug encontrado en la deteccion de arquitectura durante esta sesion:
  gemma3 se detectaba siempre como "gemma2" (mismo schema de tensores,
  pero patron de ventana distinto), y en la ruta sin config.json
  (detect_schema_safetensors) el orden de comprobaciones hacia que
  incluso cayera como "olmo2" por error (ambos tienen q_norm/k_norm).
  Corregido con una señal de tensor mas especifica.

  Bug adicional encontrado al ejecutar literalmente el ejemplo de uso
  de T5 de este mismo README (no detectado por ninguna de las pruebas
  anteriores de la sesion, que no ejercitaban este camino exacto):
  T5Runner.forward reusaba _resolve_lm_head, una funcion generica de
  ModelRunner que cae a schema["embed"] cuando no hay lm_head propio
  -- pero el schema de T5 no tiene una clave "embed" unica (tiene
  "enc_embed" y "dec_embed" por separado), asi que fallaba con
  KeyError('embed') incluso en el caso normal de tied_embeddings=True,
  que es el caso mas comun. Corregido con una resolucion de lm_head
  especifica dentro de T5Runner.forward. Verificado de nuevo tras el
  arreglo: consistencia batch/incremental identica a antes (diff
  1.27e-05), y los casos tied_embeddings=False y geglu (T5v1.1)
  siguen funcionando.

  "bart" apuntaba al mismo schema que T5 (hf_t5) pese a ser una
  arquitectura de forward distinta (posiciones absolutas + GELU, no
  bias relativo). Se retiro ese mapeo -- ver "Limitaciones conocidas".

  ALiBi (BLOOM) sigue sin implementar.

imag.py — CNN nuevas
  ResNetRunner (18/34/50/101/152), EfficientNetRunner (B0-B7, V1) y
  ViTRunner (B/16 a H/14) añadidos, formato de checkpoint torchvision.
  Antes solo MobileNet (v1/v2/v3) estaba cubierto pese a que las
  primitivas para estas arquitecturas (prelu, hardswish,
  scaled_dot_product) ya existian sueltas en ops.py sin ningun runner
  que las usara.

gguf.py
  Q8_K e IQ4_NL añadidos (tipos 15 y 20), verificados con pruebas que
  construyen el bloque binario byte a byte replicando el struct C real,
  no solo pasando arrays de NumPy a la propia funcion. IQ4_NL ademas
  verificado valor por valor contra su tabla de 16 codigos, confirmada
  con dos fuentes primarias independientes y coincidentes.

---

## Correcciones y novedades v4

ops.py — BUG CRITICO encontrado y corregido en conv2d
  weight (C_out, C_in, kH, kW) se aplanaba directo con
  .reshape(C_out, -1), en orden (C_in, kH, kW). Los parches im2col
  estan aplanados en orden (kH, kW, C_in) -- ver la variable `shape`
  dentro de la funcion. Ambos ordenes solo coinciden cuando kH=kW=1;
  para cualquier kernel real (3x3, 7x7, ...) el resultado era
  numericamente incorrecto EN SILENCIO -- sin excepcion, sin NaN, solo
  el numero equivocado. Esto afectaba a TODO uso de conv2d con kernel
  no-1x1: ResNetRunner (stem 7x7 y bloques 3x3), EfficientNetRunner
  (stem 3x3 -- las convoluciones 1x1 de expand/project eran correctas
  por la misma casualidad que ocultaba el bug), ViTRunner (patch
  embedding, kernel=stride=patch_size). depthwise_conv2d NO tenia este
  bug (verificado aparte -- al ser depthwise no pasa por un matmul con
  reshape del peso, es un producto elemento a elemento que no depende
  de ese orden). Arreglo: transponer weight a (C_out,kH,kW,C_in) antes
  de aplanar. Verificado contra una implementacion de referencia con
  loops Python explicitos (sin vectorizacion, sin compartir codigo con
  la funcion real): kernel 1x1 (regresion, sigue igual), 3x3 con
  stride/padding/bias, kernel rectangular 5x3 (para descartar
  compensacion por simetria), y el stem real de ResNet (7x7, stride=2,
  padding=3) -- los 4 casos coinciden con diff maxima <2e-5 (float32).
  Tras el arreglo, un ResNet18 sintetico completo (stem + 8 bloques
  residuales con downsample) corre end-to-end con logits finitos.

  Este bug es la razon mas probable de que EfficientNetRunner y
  ResNetRunner, aunque "verificados estructuralmente" segun la propia
  seccion de Limitaciones de la v3, no se hayan podido verificar
  numericamente contra un checkpoint real -- con este bug presente,
  esa comparacion habria fallado igualmente aunque el resto del runner
  fuera perfecto. No se puede confirmar esto con certeza (seguiria sin
  poder descargarse un checkpoint real en este entorno para comprobarlo
  directamente), pero es la hipotesis mas simple.

audio.py — modulo nuevo: WhisperEncoderRunner
  Primer soporte de audio en micronnx. Cubre el ENCODER de Whisper
  (tiny/base/small/medium/large -- misma arquitectura, distinto
  tamaño), no el decoder -- ver "Limitaciones conocidas" para el
  porque exacto de ese corte.

  Verificado contra el codigo fuente real de HuggingFace transformers
  (modeling_whisper.py, clases WhisperEncoder/WhisperEncoderLayer/
  WhisperAttention, funcion sinusoids(), leido completo) y contra
  config.json real de openai/whisper-base (d_model=512,
  encoder_layers=6, encoder_attention_heads=8, num_mel_bins=80,
  max_source_positions=1500). Los nombres de tensor del checkpoint
  (state_dict HuggingFace) se confirmaron por concordancia cruzada de
  dos scripts de conversion HF<->OpenAI independientes que coinciden
  exactamente en las mismas reglas de mapeo -- no se pudo verificar
  contra el binario .safetensors real (requiere red, no disponible en
  el entorno de desarrollo).

  Detalles de arquitectura respetados exactamente, no simplificados:
    - k_proj SIN bias; q_proj/v_proj/out_proj CON bias (WhisperAttention.
      __init__ real). Verificado que un bias parasito de k_proj presente
      en el loader pero no leido da exactamente el mismo resultado
      (diff=0.0) que sin ese tensor -- confirma que el runner
      verdaderamente lo ignora, no que la diferencia sea pequeña.
    - Positional embedding sinusoidal FIJO sumado una vez (no RoPE, no
      aprendido) para las 1500 posiciones completas, sea cual sea la
      duracion real del audio bajo el padding a 30s.
    - Sin mascara causal ni de padding -- el encoder es bidireccional
      completo (WhisperEncoderLayer.forward recibe attention_mask=None
      explicito en el codigo fuente real).
    - Longitud de entrada fija en 3000 frames de mel (formula real:
      max_source_positions * stride_conv1 * stride_conv2 = 1500*1*2);
      un input con otra longitud falla con ValueError explicito, igual
      que el codigo fuente real, en vez de rellenar/truncar en silencio.

  conv1d añadida a ops.py (mismo patron im2col que conv2d/
  depthwise_conv2d, un solo eje espacial) para el stem convolucional.
  Verificada con los parametros REALES de Whisper (kernel=3, stride=1,
  padding=1 para conv1; kernel=3, stride=2, padding=1 para conv2)
  contra una referencia con loops explicitos, y confirmando la
  reduccion real 3000->1500 encadenando ambas convoluciones.
  sinusoids() verificada contra una segunda implementacion
  independiente escrita con loops puros de Python (math.log/math.exp/
  math.sin/math.cos en vez de la ruta numpy vectorizada) para descartar
  errores de transcripcion compartidos entre ambas.

  Integrado en el resto del proyecto, no solo añadido como archivo
  aislado:
    - nx.WhisperEncoderRunner y nx.sinusoids en el API publico (__init__.py).
    - ActivationExtractor.run_whisper()/.extract_whisper() -- a
      diferencia de CNNRunner (que necesito CNNActivationExtractor
      aparte con el forward duplicado a mano, ver esa clase), Whisper
      SI tiene on_activation nativo, asi que no hizo falta duplicar
      ningun loop. reduce="temporal" añadido para (T,D)->(D,), analogo
      a "spatial" para CNN pero sobre el tensor 2D sin batch que
      produce este runner.
    - HOOK_PREFIXES ampliado con "conv1"/"conv2" (los demas prefijos
      que Whisper emite -- embed, post_attn, post_ffn, final_norm -- ya
      existian y se reutilizan tal cual).
    - detect_schema_from_tensors() (npz_runner.py) reconoce audio
      ("hf_whisper") por la señal inequivoca de conv1.weight+conv2.weight
      -- antes de este cambio, un Whisper exportado a .npz sin
      schema_name explicito habria caido en el fallback "hf_llama" de
      la cascada HF, etiquetado incorrectamente (aunque de forma
      inofensiva, ya que ese schema_name es solo informativo para audio
      -- no se indexa contra SCHEMAS como los runners de texto).

  Sin verificar (honestidad, ver tambien "Limitaciones conocidas"):
    - Sin comparacion numerica directa contra un checkpoint real
      descargado -- igual que EfficientNetRunner en la v3, la
      verificacion aqui es por-funcion (sinusoids, conv1d, cada pieza
      matematica) mas estructural end-to-end (shapes correctas en cada
      etapa via on_activation, sin NaN/Inf, con las dimensiones reales
      de whisper-tiny y whisper-base), no una comparacion logit-a-logit
      contra transformers.WhisperModel real.

pyproject.toml — BUG DE EMPAQUETADO encontrado y corregido
  find = {} junto con package-dir = {"" = "."} es namespace-aware por
  defecto: SI descubria "micronnx" como paquete de nivel superior, pero
  como namespace package VACIO (sin __init__.py en ese nivel exacto),
  mientras el codigo real vivia tres niveles mas adentro, en
  micronnx/micronnx/micronnx/ (triple anidado en el arbol de archivos
  del proyecto). Consecuencia real: `import micronnx as nx` NO fallaba
  -- pero `nx` quedaba completamente vacio, sin nx.GGUFLoader ni
  ningun otro simbolo del README. Confirmado ejecutando el algoritmo
  real de descubrimiento de paquetes de setuptools (PEP420PackageFinder)
  contra el arbol de archivos tal cual, no por inspeccion visual.
  Arreglo: `[tool.setuptools.packages.find]` con `where=["."]` +
  `include=["micronnx*"]` explicito, que aplana correctamente a los 3
  paquetes reales (micronnx, micronnx.executor, micronnx.loaders) sin
  namespace packages espurios. Verificado end-to-end con la cadena mas
  estricta posible en este entorno: build real del sdist y wheel con
  setuptools 82.0.1 (cumple el `>=77` declarado en
  `[build-system] requires`, a diferencia del 68.1.2 del sistema base,
  demasiado viejo para el formato SPDX de `license` que ya usaba este
  mismo pyproject.toml de antes de esta sesion), instalacion del wheel
  real via `pip install` en un venv limpio, y `import micronnx as nx`
  funcional con los 76 simbolos publicos operativos.

---

## Correcciones y novedades v5

forward.py / __init__.py — BUG encontrado y corregido: BertRunner
  inaccesible desde la API publica
  BertRunner existe desde hace tiempo, es completamente funcional, y
  ModelRunner.__init__ incluso lo referencia en su propio mensaje de
  error ("'hf_bert' es bidireccional. Usa BertRunner en lugar de
  ModelRunner.") -- pero nunca se registro en _PUBLIC de __init__.py.
  Consecuencia real: el propio ejemplo de BERT de este README, tal como
  estaba escrito (nx.ModelRunner(loader, schema, hp) con un schema
  hf_bert), fallaba en runtime con NotImplementedError -- confirmado
  EJECUTANDOLO, no solo leyendo el codigo. Peor: el mensaje de error de
  esa excepcion apuntaba a nx.BertRunner, que a su vez lanzaba
  AttributeError porque tampoco era accesible desde nx. Ambos corregidos:
  BertRunner registrado en _PUBLIC, y el ejemplo de BERT de este README
  corregido para usarlo. Se revisaron el resto de apariciones de
  ModelRunner en este README (11 en total) para confirmar que ninguna
  otra usaba un schema BERT por error -- todas las demas eran
  SmolLM2/LLaMA, correctas tal cual.

executor/convenience.py — modulo nuevo: nx.load(path)
  Punto de entrada unico opcional (no reemplaza nada de la API
  existente) que detecta formato + modalidad + arquitectura desde un
  archivo y devuelve el runner correcto ya listo, con hiperparametros
  inferidos donde hay certeza suficiente para hacerlo sin adivinar. Ver
  "Empezar rapido" al inicio de este README para el uso, y el propio
  docstring del modulo para el detalle completo de que se infiere y por
  que, familia por familia.

  Verificado end-to-end con checkpoints sinteticos reales en disco
  (.safetensors escritos con el formato binario oficial, leidos con
  SafeTensorsLoader real -- no loaders simulados) para las 3
  modalidades: texto (LLaMA generico, BERT, y el caso BertRunner del
  bug de arriba), audio (Whisper-tiny, los 4 hiperparametros inferidos
  coinciden exactamente con los valores reales de ese tamano), y las 5
  familias de vision (ResNet 18 y 50 -- incluyendo el caso ambiguo
  34-vs-50 con el mismo conteo de bloques, resuelto por la senal de
  Bottleneck/conv3 --, ViT B/16 y B/32 -- incluyendo el caso ambiguo con
  el mismo hidden_dim, resuelto por patch_size --, MobileNetV1,
  MobileNetV2/V3, y EfficientNet-B0 completo con sus 16 bloques MBConv
  reales, generado programaticamente a partir de las propias tablas
  internas de EfficientNetRunner para minimizar el riesgo de un
  checkpoint de prueba mal construido a mano).

  Dos bugs reales encontrados y corregidos DURANTE el desarrollo de
  este modulo, con honestidad sobre el proceso, no solo el resultado
  final:
    1. La deteccion de audio (tanto la nueva de este modulo como la
       de detect_schema_from_tensors en npz_runner.py, escrita en la
       sesion anterior) comprobaba `endswith("conv1.weight")` /
       `endswith("conv2.weight")` como senal de Whisper. Un ResNet real
       tiene layer1.0.conv1.weight y layer1.0.conv2.weight, que TAMBIEN
       terminan en esos sufijos sin ser de nivel superior -- coincidia
       en falso. No se detecto en la sesion anterior porque las pruebas
       de esa sesion solo cubrieron LLaMA y BERT, no vision. Se
       encontro al probar este modulo contra un ResNet real y
       corregido en ambos archivos: comparacion de prefijo EXACTO
       contra los 3 prefijos reales de Whisper ("", "encoder.",
       "model.encoder."), no un sufijo generico.
    2. La inferencia de n_blocks para MobileNetV2/V3 contaba bloques
       buscando la capa de EXPANSION (features.{i}.conv.0.0.weight),
       que es CONDICIONAL -- solo existe si expand_ratio != 1, y el
       primer bloque real de MobileNetV2 tipicamente no expande. El
       conteo se detenia en 0 o subestimaba. Corregido a contar por la
       capa DEPTHWISE (conv.1.0.weight), que existe en todo bloque sin
       excepcion. Se anadio ademas un warnings.warn cuando se detecta
       MobileNetV2/V3 sin que el caller confirme use_hardswish
       explicito (V2 vs V3 no se puede distinguir con certeza desde el
       checkpoint -- se asume V2 y se avisa, en vez de adivinar en
       silencio).
  Ninguno de los dos bugs llego a esta entrega sin corregir porque las
  pruebas de este modulo se ampliaron para cubrir vision real
  especificamente por haber encontrado el primero -- que a su vez solo
  aparecio por construir por fin un checkpoint ResNet de prueba en vez
  de reutilizar solo los de audio/texto ya existentes.

---

## Limitaciones conocidas (honestidad sobre lo que falta)

Estas son cosas identificadas durante el trabajo de esta sesion que
NO se implementaron, con la razon especifica de cada una -- no una
lista generica de "por hacer":

GGUF — tipos de cuantizacion sin implementar:
  IQ4_XS (tipo 23): usa la misma tabla de 16 valores que IQ4_NL, pero
    con escalas de 6 bits repartidas entre dos campos (scales_h de 2
    bits, scales_l de 4 bits) cuyo algoritmo exacto de combinacion no
    se pudo confirmar contra el codigo fuente real.
  IQ2_XXS/XS/S, IQ3_XXS/S, IQ1_S/M: requieren tablas de codebook de
    cientos o miles de valores (grid lattice E8, inicializadas en
    tiempo de ejecucion en el C real) que no se pudieron extraer y
    verificar con la misma rigurosidad que una tabla pequeña de 16
    valores. El riesgo de un error silencioso de transcripcion en una
    tabla de esa envergadura es alto.
  TQ1_0/TQ2_0 (tipos 34/35, ternarizacion): IDs de tipo confirmados,
    pero el struct de bloque exacto (empaquetamiento base-3) no se
    pudo verificar con certeza suficiente.

forward.py:
  BART: sigue sin soporte real (ver arriba). Un checkpoint BART hoy
    falla con KeyError en vez de correr con matematica incorrecta,
    pero no es soporte real.
  ALiBi (BLOOM): no implementado.

imag.py:
  EfficientNetV2: usa FusedMBConv en sus primeras etapas, una segunda
    familia de bloques con estructura de tensor distinta de V1 -- no
    cubierto.
  El puerto de HuggingFace de EfficientNet (nombres de tensor propios,
    distintos de torchvision) no esta cubierto; solo torchvision.
  EfficientNetRunner se valido con pesos sinteticos de shape correcta,
    no contra un checkpoint real descargado (sin acceso a red en el
    entorno de desarrollo) -- la validacion es estructural, no una
    comparacion numerica directa contra torchvision real. (Ver
    tambien la nota sobre el bug de conv2d en "Correcciones v4" --
    es razonable pensar que esta es la causa real de que esa
    comparacion no se haya podido cerrar antes.)

audio.py:
  Solo el ENCODER de Whisper. El decoder (causal + cross-attention
    hacia el encoder, con KV cache, vocabulario, logica de generacion
    de tokens/timestamps, supresion de tokens, diferencias
    multilingue vs .en) es una pieza sustancialmente mayor y con mas
    superficie de error que no se implemento en esta sesion. El
    encoder solo ya es util por si mismo -- produce las
    representaciones de audio que un pipeline de fusion puede
    consumir directamente (extraccion de activaciones, embeddings de
    audio, comparacion de representaciones) sin necesitar generacion
    de texto.
  El preprocesado de audio (waveform cruda -> log-mel spectrogram) NO
    esta incluido. Este runner asume que quien lo llama ya tiene el
    spectrogram como np.ndarray, tal como produce WhisperFeatureExtractor
    de HuggingFace. Calcular el spectrogram desde una waveform requiere
    una FFT + banco de filtros mel que replique exactamente esa clase
    (incluyendo su normalizacion especifica) para dar resultados
    compatibles con pesos entrenados -- no se implemento ni se
    verifico en esta sesion.
  SpecAugment (mask_time/mask_feature): solo aplica en entrenamiento
    en el codigo fuente real (`if self.training`) -- no se implemento
    a proposito, no por omision, ya que no aplica en inferencia.
  Sin verificacion numerica directa contra un checkpoint real
    descargado (ver nota completa en "Correcciones v4").

convenience.py (nx.load()):
  EfficientNet: no infiere la variante (b0..b7) -- se rechaza
    explicitamente sin variant= (ver "Empezar rapido" al inicio de
    este README para el porque). No es una limitacion temporal
    pendiente de resolver con mas tiempo -- es una decision de diseño:
    inferirla requeriria replicar y verificar el algoritmo completo de
    escalado de canales contra casos reales para tener la misma
    certeza que el resto de este modulo, y arriesgar una variante
    incorrecta en silencio va en contra del criterio del proyecto.
  MobileNetV2 vs V3: no se distinguen entre si (solo difieren en que
    bloques usan hardswish vs relu, un detalle que no queda grabado en
    ningun tensor) -- se asume V2 y se avisa con un warnings.warn.
  H5/.keras de texto (LLMs, no CNN): no cubierto -- H5Loader en este
    proyecto existe para MobileNet/Keras (vision), no hay una funcion
    detect_schema_* equivalente para LLMs en formato H5.
  .npz: fuera de alcance a proposito -- usa nx.NpzModelLoader +
    nx.detect_schema_npz directamente, que tienen su propio contrato
    (model_key) distinto al de un archivo suelto.
  num_classes de vision: nunca se infiere (se usa el default de 1000 de
    cada Runner) -- un checkpoint con un head de clasificacion de otro
    tamaño necesita num_classes= explicito en overrides.

merge.py / llm_qa.py (fusion de modelos, v0.2.3.0):
  merge_experimental() solo cubre texto, a proposito -- fusionar
    imagen con audio, o cualquier combinacion de modalidades
    distintas, se rechaza con un error explicito. No es una
    limitacion temporal: no existe ninguna interpretacion razonable
    de esa operacion (mismo criterio que compat.py, que ya le
    asignaba 0% de compatibilidad a ese caso).
  merge_experimental() con formas de ejes distintos (numero de ejes,
    no solo tamaño): si un tensor tiene 2 ejes en un modelo y 3 en el
    otro, no se recorta -- se usa el tensor de A tal cual y se
    registra como recorte. No hay ninguna operacion razonable de
    "encajar" tensores con distinto numero de dimensiones.
  El schema_name/hp del resultado de merge_experimental() son
    siempre los del modelo A -- una limitacion real, no solo una
    simplificacion: no existe una nocion bien definida de "promediar"
    dos nombres de arquitectura distintos, asi que el .npz resultante
    declara una arquitectura que estrictamente solo describia a uno
    de los dos modelos originales. Se documenta en merge_info del
    indice para que quede trazable, pero quien cargue ese .npz sin
    revisar merge_info no lo notaria.
  nx.generate() y nx.orchestrate_experimental_merge() NO se
    verificaron con un tokenizador real de HuggingFace en esta
    sesion -- la libreria `tokenizers` no estaba disponible en el
    entorno de desarrollo y no se pudo instalar sin acceso a red. Lo
    que si se verifico con rigor: el mecanismo de KV-cache + argmax +
    deteccion de EOS de forma aislada (con input_ids numericos
    directos, sin texto de por medio), el detector de corrupcion
    looks_corrupted() con 9 casos incluyendo limites exactos, y el
    flujo end-to-end completo (generate() y
    orchestrate_experimental_merge(), incluyendo el caso de
    reintento tras corrupcion forzada y el caso de fallo total) con
    un tokenizador de PRUEBA que implementa la misma interfaz que la
    libreria real (.encode().ids, .decode(), .token_to_id()) pero
    NO es la libreria real. Esto confirma que la mecanica de ambas
    funciones es correcta, pero no es lo mismo que correrlas con un
    tokenizer.json real de un modelo real -- antes de usar estas dos
    funciones con algo importante, se recomienda probarlas primero
    con el checkpoint y tokenizador reales que se vayan a usar.
  generate() usa greedy decoding (siempre el token de mayor
    probabilidad) sin ninguna opcion de temperatura/top_k/top_p --
    deliberado para que comparar respuestas antes/despues de fusionar
    sea determinista, no porque muestreo con temperatura no tenga
    valor en general.
  generate() no soporta BertRunner (no autoregresivo, greedy decoding
    no tiene sentido en un encoder bidireccional) ni T5Runner (protocolo
    de dos pasos encode()+forward() distinto, no cubierto).

bridge.py (nx.bridge_image_to_text(), v0.2.4.0):
  NO es un modelo multimodal real -- ver la advertencia completa en el
    docstring del modulo y en cada ImageTextBridgeResult.warning. Esto
    no es una limitacion temporal que se resolvera en una version
    futura con mas esfuerzo: conectar modalidades de verdad requiere
    entrenamiento (backpropagation), que no existe en absoluto en
    micronnx y no esta planeado para el proyecto tal como esta
    concebido hoy.
  Solo cubre ModelRunner (decoder-only) para el lado de texto --
    BertRunner y T5Runner se rechazan explicitamente (verificado el
    rechazo). No hay ninguna razon tecnica profunda para esta
    limitacion mas alla de que no se implemento en esta sesion; a
    diferencia de generate(), donde BertRunner/T5Runner no tienen
    sentido conceptual, aqui SI podria extenderse en el futuro.
  La proyeccion SVD asume que "preservar la norma/ortonormalidad de la
    matriz" es una eleccion razonable para "no distorsionar demasiado"
    el vector proyectado -- esto es una decision de diseño, no una
    tecnica con garantias formales de que el resultado sea
    significativo. Una matriz aleatoria sin ortonormalizar, o
    cualquier otra eleccion de proyeccion fija, seria igualmente
    valida "sin entrenar" -- no hay una unica forma correcta de hacer
    esto, porque la operacion en si no tiene una nocion de
    "correccion" bien definida.
  No se probo con imagenes ni modelos de texto reales (solo checkpoints
    sinteticos con pesos aleatorios de inicializacion) -- lo que se
    verifico con rigor es la MECANICA (shapes correctas, determinismo,
    que el resultado es consumible por ModelRunner.forward() sin
    errores), no que el resultado producido por un ResNet real
    entrenado y un LLM real entrenado tenga ningun valor practico --
    la expectativa honesta, dado como esta construido, es que no lo
    tenga en la gran mayoria de los casos.

ML clasico (arboles de decision, ensambles, modelos lineales, SVM):
  Discutido en esta sesion pero NO implementado. Queda como trabajo
  futuro con el formato de entrada (pickle/joblib vs un formato propio

  mas seguro) y el orden de prioridad (arboles vs modelos lineales)
  por decidir.

Archivos existentes con incoherencias encontradas durante la revision
de esta sesion, sin modificar (fuera del alcance acordado):
  micronnx/loaders/exporter.py (50 lineas) parece un archivo huerfano:
    su docstring dice "micronnx.executor -- ops, forward pass...",
    reexporta simbolos de micronnx.executor.*, pero vive en
    micronnx/loaders/ y micronnx/loaders/__init__.py no lo importa en
    absoluto (solo importa safetensors, gguf, h5, npy). Nada en el
    proyecto parece importar desde micronnx.loaders.exporter. Es
    candidato a ser basura de un refactor anterior sin limpiar, pero
    no se elimino ni modifico porque no se confirmo con certeza que
    sea seguro hacerlo sin mas contexto del historial del proyecto.
  micronnx/executor/canonical.py y micronnx/executor/npz_runner.py
    (el sistema de mapeo canonico usado por export_to_npz, paralelo al
    SCHEMAS/detect_schema_* de forward.py) no tienen ningun concepto
    de sliding_window ni de la deteccion diferenciada gemma2/gemma3
    añadida en esta sesion. Un modelo exportado a .npz y recargado via
    NpzModelLoader hoy NO llevaria consigo el sliding window real,
    aunque cargado directamente desde el .gguf/.safetensors original
    si funciona correctamente. Tampoco tienen conocimiento de T5Runner
    (canonical.py si tiene reglas de mapeo de nombres para T5, de
    antes de esta sesion, y coinciden con los nombres usados en
    T5Runner -- pero npz_runner.py no sabe instanciar T5Runner en vez
    de ModelRunner para un modelo con schema hf_t5).
  micronnx/executor/exporter.py (el exportador real, 638 lineas) no
    tiene ninguna referencia a T5Runner ni a sliding_window -- no se
    confirmo si el proceso de exportacion a .npz preserva estos datos
    nuevos correctamente para los modelos que los usan.

---

## Dependencias

  numpy >= 1.24
  pyfive  (para archivos .h5 / .keras)
  tokenizers >= 0.15  (OPCIONAL — solo para nx.generate() y
                        nx.orchestrate_experimental_merge();
                        pip install micronnx[llm-qa])

---

## Parte del ecosistema UFM

micronnx es la capa de recoleccion de UFM (Unified Fusion Model).
A partir de v0.2.3.0, la fusion REAL de modelos ya vive aqui, no solo
como anuncio -- ver la seccion completa mas abajo. Desde v0.2.4.0
tambien incluye un puente geometrico SIN ENTRENAR entre vision y texto
(nx.bridge_image_to_text()) -- no confundir con un modelo multimodal
real, que requeriria entrenamiento (backpropagation), algo que
micronnx no tiene ni tuvo nunca. Lo que sigue viviendo en UFM (no en
micronnx) es la logica de mas alto nivel: decidir QUE modelos
fusionar, para que fin, el ajuste fino posterior al modelo resultante,
y cualquier forma real de conectar modalidades distintas que requiera
entrenamiento de verdad. micronnx expone las piezas mecanicas:
comparar, combinar, verificar, y (desde v0.2.4.0, con las limitaciones
ya dichas) conectar geometricamente sin aprender nada.

### Historial de esta seccion

v0.2.2.0: se unifico el patron de instrumentacion entre las tres
modalidades (ModelRunner/BertRunner/T5Runner para texto,
ResNetRunner/EfficientNetRunner/ViTRunner para vision,
WhisperEncoderRunner para audio -- todos con forward(entrada,
on_activation=callback), todos leidos por el mismo ActivationExtractor)
como base para un futuro trabajo de fusion, sin escribir aun ningun
codigo de fusion en si.

v0.2.3.0: la fusion real ya existe, en tres piezas
separadas segun su nivel de garantia:

  nx.compatibility(runner_a, runner_b) -- YA NO es interno (en v0.2.2.0
    vivia solo en compat.py, sin registrar en la API publica; ahora
    esta expuesto). Calcula un % de compatibilidad ESTRUCTURAL entre
    dos modelos ya cargados, sin tocar ningun peso (streaming real --
    verificado con un loader "trampa" que revienta si algo intenta
    cargar un tensor durante la comparacion, corrido contra dos
    checkpoints reales de ~140MB combinados sin dispararse ni una vez).
    Modalidades distintas (texto vs imagen vs audio) dan 0% con un
    aviso explicito de riesgo de corrupcion; misma modalidad pero
    arquitectura distinta da 5%; mismo tipo de runner da un score
    compuesto por coincidencia de hiperparametros de shape (70% del
    score) y de "familia" -- norm_type/ffn_type para texto,
    solapamiento de nombres de tensor para vision/audio (30% del
    score). threshold configurable, 0.6 (60%) por defecto.

        runner_a, _ = nx.load("modelo_a.safetensors")
        runner_b, _ = nx.load("modelo_b.safetensors")
        result = nx.compatibility(runner_a, runner_b)
        if result.risky:
            print(result.warning)

  nx.merge_same_architecture(runner_a, runner_b, output_path,
    min_compatibility=0.9) -- fusion REAL (no experimental) para
    modelos con la MISMA arquitectura exacta. Cada tensor se promedia
    (media aritmetica simple, misma tecnica que "model soups" para
    modelos afines) -- streaming tensor por tensor, nunca ambos
    modelos completos en RAM (verificado: el pico de memoria esta
    acotado por el tamaño del tensor individual mas grande del
    checkpoint, no por el tamaño total del modelo -- confirmado
    forzando 200 tensores del tamaño mas grande real y viendo que el
    RSS del proceso se estabiliza en vez de seguir creciendo). Exige
    al menos 90% de compatibilidad real antes de intentarlo -- mas
    estricto que el 60% por defecto de nx.compatibility(), porque aqui
    no es solo un aviso de riesgo, es la condicion para que promediar
    tensor a tensor tenga sentido matematico en absoluto. El resultado
    es un .npz con el mismo formato exacto que produce
    nx.export_to_npz(), cargable directamente con NpzModelLoader.

        result = nx.merge_same_architecture(runner_a, runner_b, "fusionado.npz")
        loader = nx.NpzModelLoader("fusionado.npz")
        fusionado = nx.ModelRunner(loader, loader.schema_name, loader.hp)

  nx.merge_experimental(runner_a, runner_b, output_path) -- EXPERIMENTAL
    a proposito: para arquitecturas de TEXTO distintas (solo texto --
    fusionar imagen con audio, o cualquier combinacion de modalidades
    distintas, sigue sin tener ninguna interpretacion razonable y se
    rechaza con un error explicito, sin excepcion). Para cada tensor:
    si esta en ambos modelos con la misma forma, se promedia; si esta
    SOLO en uno (ej. los biases de atencion q_bias/k_bias/v_bias/
    o_bias que Qwen2/StableLM tienen y un LLaMA estandar no --
    verificado con ese caso exacto, construyendo un checkpoint con
    biases y otro sin ellos, con valores conocidos: el tensor faltante
    se rellena con el valor REAL del modelo que si lo tiene, nunca con
    ceros ni relleno inventado -- confirmado numericamente); si esta en
    ambos pero con formas distintas (ej. vocab_size diferente), se
    recorta cada eje al tamaño MENOR de los dos y se registra
    explicitamente en el resultado que tensores se recortaron. El
    resultado puede no comportarse como ninguno de los dos modelos
    originales -- no hay forma de saberlo de antemano sin correrlo, que
    es exactamente para lo que sirve la siguiente pieza.

  nx.orchestrate_experimental_merge(runner_a, runner_b, tokenizer,
    question, output_path, max_attempts=3) -- el mecanismo completo de
    verificacion: le hace `question` a runner_a y runner_b POR
    SEPARADO antes de fusionar (guarda ambas respuestas), fusiona con
    merge_experimental(), le hace la MISMA pregunta al modelo
    fusionado, y usa nx.looks_corrupted() para detectar si la
    respuesta tiene rachas de caracteres repetidos sin sentido (el
    ejemplo pedido fue arrobas repetidas tipo "@@@@@@@@" -- la deteccion
    real cubre cualquier caracter no alfanumerico repetido 4+ veces
    seguidas, no solo ese simbolo). Si detecta corrupcion, reintenta
    (invirtiendo el orden de los modelos, la unica variacion de
    estrategia disponible con merge_experimental() tal como esta
    definida) hasta max_attempts veces -- verificado forzando
    corrupcion artificial en el primer intento y confirmando que
    reintenta y tiene exito en el segundo, y tambien el caso donde
    TODOS los intentos fallan (success=False, sin ningun archivo
    temporal huerfano en disco). Si algun intento funciona, el usuario
    ve directamente la respuesta nueva del modelo fusionado -- no hace
    falta que inspeccione nada mas.

    Esto SI requiere generacion de texto real (pregunta en palabras
    humanas, no solo numeros), que no existia en absoluto en ninguna
    version anterior de micronnx -- nx.generate() es un loop de
    greedy decoding nuevo, escrito para esta version, que reutiliza el
    KV-cache YA EXISTENTE de ModelRunner (nunca reimplementa el
    forward pass) pero necesita un tokenizador real para convertir
    texto<->numeros. Ese tokenizador NO viene incluido en micronnx --
    requiere la libreria `tokenizers` de HuggingFace instalada aparte
    (`pip install tokenizers`, dependencia OPCIONAL, solo para
    nx.generate()/nx.orchestrate_experimental_merge() -- el resto de
    micronnx no la necesita para nada). Sin ella, ambas funciones
    lanzan nx.TokenizerUnavailable con un mensaje explicando
    exactamente que instalar, en vez de un ModuleNotFoundError sin
    contexto.

    Limitacion honesta: no se pudo verificar generate()/
    orchestrate_experimental_merge() con un tokenizador REAL de
    HuggingFace en esta sesion -- la libreria `tokenizers` no estaba
    disponible en el entorno de desarrollo y no se pudo instalar sin
    acceso a red. Lo que si se verifico con rigor: el mecanismo de
    KV-cache + argmax + deteccion de EOS por separado (con input_ids
    numericos directos, sin pasar por texto), el detector de
    corrupcion looks_corrupted() de forma aislada (9 casos, incluyendo
    limites exactos), y el flujo completo end-to-end usando un
    tokenizador de prueba que implementa la misma interfaz que la
    libreria real (.encode().ids, .decode(), .token_to_id()) para
    poder ejercitar generate() y orchestrate_experimental_merge() sin
    la dependencia real instalada. Esto confirma que la MECANICA
    funciona correctamente, pero no es lo mismo que correrlo con un
    tokenizer.json real de un modelo real -- quien use estas dos
    funciones deberia probarlas con su propio checkpoint+tokenizer
    antes de confiar en el resultado para algo importante.

v0.2.4.0 (esta version):

  Nomenclatura: "incompatibles" reemplaza a "experimental" para
    nx.merge_experimental() (fusion de arquitecturas de TEXTO
    distintas) -- el nombre de la funcion en si no cambio (seria un
    cambio de API rompiente sin ningun beneficio real), pero en la
    documentacion de cara al usuario se le llama "incompatibles" para
    dejar la palabra "experimental" exclusivamente para el puente
    imagen-texto de abajo, que es el que de verdad merece esa
    etiqueta: nx.merge_experimental() sigue siendo una operacion bien
    definida matematicamente (promedio de tensores que coinciden en
    forma, valores reales para los que no coinciden) aunque el
    resultado final no tenga garantia de "funcionar bien" -- el
    puente, en cambio, ni siquiera tiene una operacion con significado
    semantico claro detras.

  nx.bridge_image_to_text(vision_runner, text_runner, image,
    prompt_ids, k=5, seed=0) -- NUEVO, "incompatibles" ampliado para
    cubrir el caso pedido explicitamente: conectar un modelo de VISION
    con uno de TEXTO. Antes de construir esto, se establecio con
    claridad que la forma "real" de conectar dos modalidades (una capa
    adaptadora ENTRENADA con datos, como hacen CLIP/LLaVA) requiere
    backpropagation, que no existe en absoluto en micronnx (confirmado
    con busqueda exhaustiva en todo el codigo: cero resultados para
    backward/gradient/autograd/optimizer en cualquier archivo). Se
    presentaron 4 opciones reales (proyeccion fija sin entrenar,
    placeholder vacio, motor de backprop real, o no hacer nada) y se
    eligio la primera.

    Como funciona, sin ambiguedad (ver tambien el docstring completo
    de bridge.py, que es la fuente de verdad definitiva): se extrae el
    vector de features de la imagen ANTES del head de clasificacion
    (la activacion "pooled" via on_activation para ResNet/
    EfficientNet/MobileNet -- verificado con ResNet18 real, vector
    (512,) correcto; para ViT, "encoder_final_ln"[0], verificado que
    es BIT A BIT IDENTICO al cls_out interno de ViTRunner.forward(),
    diff=0.0 exacto, no una aproximacion). Se proyecta ese vector al
    espacio de embedding del modelo de texto con una matriz FIJA
    (semilla determinista, ortonormalizada via SVD -- verificado que
    P @ P.T se aproxima a la identidad, diff ~5e-7, error de punto
    flotante no error real). Se buscan las k palabras del VOCABULARIO
    REAL del modelo de texto mas cercanas por similitud coseno a ese
    punto proyectado (verificado con un caso controlado donde se sabia
    de antemano cual era el vecino mas cercano correcto). Esas k
    palabras se anteponen al prompt como input_ids NORMALES -- el
    resultado se pasa a text_runner.forward() SIN NINGUN CAMBIO en
    ModelRunner (se decidio deliberadamente NO modificar forward.py
    para aceptar un embedding continuo directamente, porque tocar el
    calculo de T/positions/causal_mask ahi arriesgaba romper el
    forward para TODO EL MUNDO que usa ModelRunner, no solo para este
    puente experimental -- generar input_ids reales y dejar que el
    forward existente los procese es mas seguro, a costa de ser una
    idea mas simple). Verificado end-to-end: los input_ids combinados
    corren sin ningun error por el forward real de un ModelRunner.

    Reproducible (misma imagen + mismo seed -> mismos tokens,
    verificado) pero SIN NINGUNA garantia de que el resultado tenga
    sentido -- cada ImageTextBridgeResult trae un .warning con esta
    misma advertencia, para que este disponible sin tener que leer la
    documentacion. Solo cubre ResNetRunner/EfficientNetRunner/
    CNNRunner/InvertedResidualRunner/ViTRunner para vision y
    ModelRunner para texto (BertRunner/T5Runner rechazados
    explicitamente, verificado el rechazo).

  nx.merge_same_architecture() -- mas rapido y con una opcion nueva de
    confiabilidad:
      Velocidad (medida, no supuesta): se perfilo con cProfile antes
      de tocar nada, y se encontro que .astype() dominaba el tiempo
      total (0.161s de 0.314s en un caso de prueba real). La causa:
      el promedio pasaba por float64 antes de volver a float32 (4
      conversiones de tipo por tensor). Se verifico numericamente
      (matrices 1000x1000 float32 aleatorias) que ese paso por float64
      no aporta NINGUNA precision real para esta operacion -- diff=0.0
      exacto entre promediar en float64 vs quedarse en float32,
      ademas de que el resultado se trunca a float16 al final de
      todas formas. Se elimino ese paso (de 4 conversiones a 2).
      Resultado medido (5 corridas, ResNet18 real): de ~0.42s a
      ~0.17-0.19s, aproximadamente 2.2x mas rapido. Mismo cambio
      aplicado tambien en nx.merge_experimental() (ambas ramas: misma
      forma y recorte por forma distinta), por consistencia.
      weight_a (nuevo, default=0.5): promedio PONDERADO en vez de
      50/50 fijo -- weight_a=0.5 dara el mismo resultado exacto que
      antes de este parametro (verificado: sigue dando 4.0 para el
      caso conocido 2.0+6.0); weight_a=0.25 da 5.0 (=0.25*2.0+0.75*6.0,
      verificado); weight_a=1.0 reproduce el modelo A tal cual
      (verificado); weight_a fuera de [0,1] se rechaza con error
      claro. Registrado en merge_info del indice resultante
      (weight_a/weight_b) para que quede trazable que ponderacion se
      uso.

---

## Licencia

MIT
