Metadata-Version: 2.4
Name: compickle
Version: 1.2.6
Summary: Biblioteca de serializacion binaria rapida para Python, con motor en C y estilo de API similar a pickle
Author: Luis Fernando Montaño Hernandez
License-Expression: MIT
Project-URL: Homepage, https://github.com/lmontanohernandez8-png/Micronnx
Project-URL: Repository, https://github.com/lmontanohernandez8-png/Micronnx
Keywords: serializer,pickle,binary,performance,python,serialization
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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: Programming Language :: C
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Archiving
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

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

# 🥒 compickle

**Serialización binaria para Python con motor en C — rápido, compacto y sin dependencias.**

[

![Python](https://img.shields.io/badge/Python-3.9%2B-blue?style=flat-square&logo=python&logoColor=white)

](https://www.python.org/)
[

![Motor C](https://img.shields.io/badge/Motor-C%20nativo-orange?style=flat-square&logo=c&logoColor=white)

]()
[

![Versión](https://img.shields.io/badge/versión-1.2.4-blue?style=flat-square)

]()
[

![Licencia](https://img.shields.io/badge/Licencia-MIT-green?style=flat-square)

]()

---

## ¿Qué es compickle?

`compickle` es un serializador binario, distribuido como paquete real
(`compickle/__init__.py` como punto de entrada perezoso), con motor en **C**
(`compickle/compickle.c`, expuesto como extensión nativa `compickle._compickle`) y un
**fallback puro en Python** (`compickle/compickle.py`) que implementa exactamente el
mismo protocolo binario, para cuando la extensión C no está compilada o no se puede
importar en el entorno de destino. `__init__.py` decide cuál de los dos backends usar
y expone la API pública (`dumps`, `loads`, `dump`, `load`, `verify_stream`,
`dedup_reset`, `backend`) — el resto de este documento describe esa API, y cuando
haga falta detallar el mecanismo interno, indica explícitamente si algo vive en
`__init__.py` o en el submódulo `compickle.compickle`.

> **Versión actual: `1.2.4`**

---

## 📊 Estado medido (honesto)

### compickle-C vs `pickle` (protocolo 5, acelerador `_pickle`)

Medido en 6 cargas de trabajo representativas (listas de enteros, strings repetidos,
diccionarios anidados, tuplas mixtas, estructuras grandes anidadas):

| | serialize | deserialize |
|---|---|---|
| compickle-C vs pickle-C | **~1.8x más rápido** | **~1.75x más rápido** |

compickle-C ganó en los 6 casos probados, sin excepción, con bytes de salida más
compactos en todos ellos (en el caso más grande, casi la mitad del tamaño de `pickle`).

### compickle-Python puro vs `pickle`-Python puro

Forzando la implementación pura Python de `pickle` (`pickle._Pickler`/`_Unpickler`,
evitando el acelerador `_pickle` que `pickle.dumps` usa por defecto de forma
transparente):

| | serialize | deserialize |
|---|---|---|
| compickle-Py vs pickle-PurePy | **~3.2x más rápido** | **~2.5x más rápido** |

**Alcance:** estas cifras salen de 6 workloads elegidos como representativos, no de
una prueba exhaustiva. No se probaron referencias circulares extensas, arrays de
NumPy, payloads de cientos de MB, ni patrones de acceso parcial al stream.

> ⚠️ **Estos números son de una ronda de trabajo anterior a la de los bugs #9-#11 más
> abajo**, y el dataset de esa ronda no incluía el patrón "muchas instancias
> compartiendo una estructura grande" que los bugs #9/#10 afectaban — por eso estas
> cifras no reflejan la mejora de esa ronda posterior. Ver la sección
> [⚡ Rendimiento tras los fixes de dedup y dispatch](#-rendimiento-tras-los-fixes-de-dedup-y-dispatch-medido-no-estimado)
> más abajo para los números de esa ronda específica, medidos por separado.

### Mejora del backend Python puro sobre su propia versión anterior (ronda de `_fast_deser`)

Tras una ronda de optimización dirigida por profiling: **entre +13.7% y +22.6%**
combinado (serialize + deserialize). El mayor aporte vino de resolver los 4 tags más
frecuentes (entero pequeño, referencia, string corta, entero de 1 byte) directamente
en el bucle de listas/dicts en `_deserialize`, evitando entrar a la función completa
de despacho cuando no hace falta. Esta optimización sigue vigente en el código actual
(el helper `_fast_deser`), y se complementa con el dispatch por diccionario para el
resto de los tags — ver bug #11 más abajo.

---

## 🐛 Bugs corregidos

Se encontraron y corrigieron **cinco bugs de corrección** preexistentes (no
introducidos en esa ronda; confirmado comparando byte a byte contra el código
anterior). Todos compartían la misma raíz: el opcode `0xFB`/`0x0E` que envuelve datos
deduplicados no siempre preservaba el `tag` original al leerlo de vuelta.

**En `compickle.py`:**
1. Pérdida del tag de compresión en clases con código fuente corto (`_emit_new` usaba
   el opcode genérico `0x0E` para cualquier bloque ≤63 bytes, incluso el comprimido
   con zlib) — causaba `UnicodeDecodeError` al deserializar.
2. `bytes`/`bytearray` no coincidían byte a byte con el backend C al deduplicar: el
   tag se escribía antes de saber si el dato sería una referencia o uno nuevo,
   duplicándolo en cada repetición.

**En `compickle.c`:**
3. `read_dedup_bytes` nunca descomprimía la fuente de clase: la rama `0xFB` descartaba
   el tag almacenado sin comprobar si era el de "comprimido".
4. `write_dedup` tenía el mismo bug que el punto 1, del lado C: bloques comprimidos
   ≤63 bytes perdían el tag al escribirse.
5. **El más serio de esa ronda:** `write_class_header` registraba una entrada de
   deduplicación redundante que nunca se emitía al stream, desincronizando
   permanentemente el contador de índices de referencia entre escritor y lector en
   cuanto se serializaba más de una clase o más de una instancia de la misma clase.

Tras esos cinco fixes, la suite de regresión (55 casos: todos los tipos soportados,
valores límite, anidamiento profundo, deduplicación por identidad vs. por contenido,
y clases mezcladas y repetidas) pasa **55/55 en roundtrip** y **55/55 en
cross-validation byte-idéntica entre ambos backends**.

**Encontrados en la ronda de `show_source`/reestructuración a paquete real:**

6. `compickle` no era un paquete de verdad — faltaba `compickle/__init__.py` en el
   código fuente que se estaba versionando (existía en el repo real pero no se había
   incluido). Sin él, `compickle.py` se importaba directamente como módulo de nivel
   superior, y `_compickle.c` compilaba como extensión suelta en vez de vivir dentro
   del paquete. Corregido: estructura real `compickle/{__init__.py, compickle.py,
   compickle.c}`, `setup.py` compilando `compickle._compickle` (no `_compickle`
   suelto), `pyproject.toml` declarando `packages = ["compickle"]`. Verificado con una
   instalación real vía `pip install .` en un entorno aislado, confirmando que el
   `.so` cae dentro de `site-packages/compickle/` junto a `__init__.py`.
7. La extracción de fuente comprimida con zlib para `show_source=True` no se
   descomprimía del lado C — el bloque `0xFB` capturaba los bytes crudos aún
   comprimidos y los pasaba tal cual, produciendo binario ilegible en vez de código
   fuente. Corregido reusando en la captura el mismo patrón de reintento de buffer
   que `OP_SRC_Z` ya usa en `read_dedup_bytes`, para que `dedup_table` en C guarde
   siempre bytes ya descomprimidos, igual contrato que el backend Python puro.

**Encontrado en la ronda de `classmethod`/`staticmethod`/`property`:**

8. **Crítico — corrupción de memoria del proceso completo.** `deserialize()` en
   `compickle.c`, al reconstruir cualquier función (`tag == 0x20`, la ruta que usan
   *todas* las funciones sin `__reduce__` propio), obtenía el `__dict__` de
   `builtins` vía `PyModule_GetDict()` — que devuelve una **referencia prestada**
   (no nueva) según la C API de CPython — y luego hacía `Py_XDECREF()` sobre ese
   resultado como si fuera una referencia propia. Cada `loads()` de una función
   decrementaba el refcount de `builtins.__dict__` (un objeto compartido
   globalmente por todo el proceso) en 1 de más, sin haberlo incrementado nunca.
   El refcount inicial es alto (cientos, por todas las referencias legítimas del
   intérprete), así que el bug sobrevivía sin síntomas visibles durante cientos de
   llamadas — hasta que el contador llegaba a 0 y CPython intentaba liberar memoria
   en uso activo por todo el proceso, con **segfault no determinista** en algún
   punto posterior, no en la llamada que de hecho causó el problema. Corregido
   diferenciando explícitamente el caso `PyModule_GetDict()` (prestada, nunca se
   decrementa) del caso `PyDict_New()` de fallback si `import builtins` fallara
   (nueva, sí se decrementa). Verificado con 3000 llamadas repetidas post-fix sin
   crash y con el refcount de `builtins.__dict__` perfectamente estable llamada a
   llamada.

**Encontrados en la ronda de optimización de velocidad (dedup de contenedores y
dispatch de lectura):**

9. **El más serio de esta ronda — `write_class_header` en C recomprimía con zlib en
   cada instancia, no en cada clase.** El código consultaba la tabla de dedup
   *después* de comprimir, no antes — así que con 50 000 instancias de la misma
   clase, se ejecutaba `compress2()` sobre el mismo texto fuente 50 000 veces y se
   descartaba el resultado 49 999 de esas veces, porque ya estaba deduplicado.
   Confirmado perfilando el motor C con contadores manuales: esa función se comía
   más del 260% del tiempo total de `dumps()` en un benchmark de 10 000 instancias
   (227ms → 12.6ms tras el fix). Corregido con un caché nuevo (`class_header_cache`,
   keyed por `id(cls)`) que calcula `(tag, payload)` una sola vez por clase y
   proceso. Verificado con `cmp` byte a byte: el stream de salida es idéntico al de
   antes del fix, tanto en un caso simple como en uno de 2000 instancias mezclando
   dos clases.

10. **`dict` y `list` nunca deduplicaban por identidad — solo `str`/`bytes`/
    `bytearray` lo hacían.** Un mismo diccionario u objeto lista, compartido por
    referencia entre miles de instancias, se re-serializaba completo cada vez
    (~32 bytes por repetición) en vez de emitir una referencia corta (~2 bytes,
    igual que ya hacían `pickle`/`dill`/`cloudpickle` de fábrica). En un caso real
    de 50 000 instancias compartiendo un `dict` de perfil, esto representaba la
    diferencia entre 2.82MB y 1.32MB de salida, y entre 5.9 segundos y 48ms de
    `dumps()`. Corregido agregando dos tags nuevos (`0x13` para dict, `0x14` para
    list) con dedup de identidad de puntero, en ambos backends — el formato viejo
    (`0x08`/`0x0C` sin dedup) se sigue leyendo sin cambios para compatibilidad
    retroactiva; ver la nota en la tabla de tipos soportados más abajo sobre qué tag
    emite hoy cada uno. Efecto colateral positivo confirmado: un `dict`/`list`
    auto-referenciado (que se contiene a sí mismo, directa o indirectamente) antes
    causaba **segfault** en el motor C (recursión sin límite, sin protección) y
    `RecursionError` en el backend Python; con el registro-antes-de-llenar que este
    fix requiere, ahora resuelve correctamente con la referencia circular
    preservada. Verificado con streams construidos a mano, cross-validación entre
    ambos backends, y 20+ casos de test incluyendo mezcla de clases, listas
    compartidas anidadas, y dicts sin compartir (para confirmar que el caso sin
    dedup no regresionó).

11. **`_deserialize` (backend Python) recorría hasta 39 comparaciones secuenciales
    para tags poco frecuentes pero legítimos — incluido el caso de instancia con
    `__dict__`, el más común en payloads con muchas clases de usuario.** Medido:
    ~430ns por llamada en esa posición de la cadena, ~21ms de puro overhead de
    comparación en un dataset de 50 000 instancias. Corregido convirtiendo esa
    cadena en dispatch por diccionario (`_TAG_HANDLERS`, O(1) sin importar el tag),
    mientras que los 4 tags más frecuentes se mantuvieron inline al principio de
    `_deserialize` (ver la sección anterior) — medido explícitamente que para esos 4
    casos la comparación directa sigue ganando al lookup de diccionario, por el
    overhead de la llamada de función adicional. En el camino se encontró y removió
    una rama de código genuinamente muerta: el tag `0x12` tenía dos bloques de
    lectura distintos en el código de esa ronda ("entero negativo pequeño" y, mucho
    más abajo, "class object" standalone) — el segundo era matemáticamente
    inalcanzable, porque el primero siempre lo intercepta antes con un `return`.
    Confirmado construyendo un stream a mano con tag `0x12` y verificando que
    **siempre** resuelve como entero negativo, nunca como clase; confirmado también
    que ningún camino de escritura del backend actual emite `0x12` con el
    significado de clase — la serialización de clases pasa exclusivamente por
    `0x1C`/`0x1E` vía `_write_class_header`. Verificado con 34 checks cubriendo cada
    tipo de tag soportado, más cross-validation de que un stream generado por el
    backend C se sigue leyendo correctamente por el Python tras el refactor.

**Pendiente de decisión, no corregido:** en el motor C, `py_dedup_reset()` limpia
`source_cache`, `exec_cache`, y `class_header_cache`, pero no `cls_cache` — a pesar
de que el docstring de `dedup_reset()` en el lado Python promete limpiar "los
cachés de source/exec/clases". Es decir, tras llamar `compickle.dedup_reset()` con
el backend C activo, las clases ya resueltas siguen cacheadas en `cls_cache`
indefinidamente. Se dejó una nota explícita en el propio código C en el punto exacto
donde se resolvería, en vez de corregirlo sin confirmación previa.

---

## ⚡ Rendimiento tras los fixes de dedup y dispatch (medido, no estimado)

Todo lo de esta sección viene de benchmarks reales comparando contra `pickle`,
`dill`, y `cloudpickle` sobre el mismo dataset: 50 000 instancias de una clase con
un `dict` de perfil compartido por todas ellas — el patrón exacto que los bugs #9 y
#10 afectaban.

**Backend C:**

| | pickle | dill | cloudpickle | compickle (antes de #9/#10) | compickle (después) |
|---|---|---|---|---|---|
| Serializar | ~50-75ms | ~1000-1300ms | ~105-140ms | 5904ms | **47.6ms** |
| Tamaño de salida | 1.50MB | 1.50MB | 1.50MB | 2.82MB | **1.32MB** |

**Backend Python puro** (mismo dataset, forzando el fallback sin extensión C):

| | pickle | dill | cloudpickle | compickle-Py |
|---|---|---|---|---|
| Serializar | ~50-75ms | ~1000-1300ms | ~105-140ms | ~240ms (~3.4x pickle) |
| Deserializar | ~45-70ms | ~50-62ms | ~44-57ms | ~290ms (~4.4x pickle) |

> El número de "antes" para el backend Python puro en este mismo escenario no se
> midió directamente antes de aplicar el fix — se infiere que sería del mismo orden
> que el backend C antes del fix (~5900ms), porque el bug de fondo (#10) era
> idéntico en ambos backends. Si se necesita el número medido exacto, hay que
> revertir el fix sobre una copia y correr el benchmark — no se hizo en esta ronda
> por no ser necesario para decidir si valía la pena el fix.

**Contexto necesario para leer estos números correctamente:** el salto grande
(hasta ~124x en el caso de C) no es optimización de algoritmo — es la consecuencia
matemática de haber estado haciendo trabajo redundante decenas de miles de veces.
Fuera del patrón específico "muchas instancias compartiendo una estructura grande",
el motor ya rendía bien antes de estos fixes: en datasets sin ese patrón (dicts
sueltos, funciones simples, listas de enteros), compickle-C ya le ganaba a
`pickle`/`dill`/`cloudpickle` desde antes — ver la sección
[📊 Estado medido](#-estado-medido-honesto) más arriba.

---

## 🔒 `verify_stream()` — inspección sin ejecución

`loads()` no es un lector de datos puro: **nueve** de sus opcodes (`0x0D`, `0x1C`,
`0x1E`, `0x1F`, `0x20`, `0x21`, `0x22`, `0x23`, `0x24` — función vía fuente,
instancia con `__dict__`/`__slots__`, objeto `__reduce__`, función y code-object vía
`marshal`, y los tres envoltorios `classmethod`/`staticmethod`/`property`, que
internamente delegan a la función que envuelven) ejecutan código como parte de
reconstruir el objeto. Esto es necesario para lo que esas rutas hacen, pero
significa que un archivo `.cpkl` no confiable puede ejecutar código arbitrario al
cargarlo con `loads()`.

`verify_stream(data)` camina la estructura completa de un stream **sin construir
ningún objeto real**: nunca llama `exec()`, nunca llama `marshal.loads()` sobre nada
que vaya a ejecutarse, nunca invoca un callable. Confirma que los tags son válidos,
que las longitudes no se salen del buffer, y que toda referencia de deduplicación
apunta a un índice que realmente existe — y, cuando encuentra uno de los nueve
opcodes que requieren ejecución, no intenta "validar" su contenido (no hay forma de
validar código arbitrario sin ejecutarlo): calcula dónde termina ese bloque usando
solo aritmética de longitudes, lo registra como no verificado, y sigue caminando
el resto.

```python
import compickle

reporte = compickle.verify_stream(datos_no_confiables)

if not reporte.ok:
    print("Stream corrupto o mal formado:", reporte.error)
elif reporte.fully_verified:
    print("Todo el árbol es dato puro — cero ejecución necesaria en loads()")
else:
    print(f"{len(reporte.unexecuted_blocks)} bloque(s) requieren ejecución para verse:")
    for pos, tag, motivo, _ in reporte.unexecuted_blocks:
        print(f"  offset {pos}: tag 0x{tag:02X} — {motivo}")
```

**Lo que `verify_stream()` garantiza y lo que no garantiza — sin rodeos:**

- `reporte.ok == False` → el stream está corrupto o mal formado (tag desconocido,
  longitud que se sale del buffer, referencia a un índice que no existe, bytes
  sobrantes tras el objeto raíz). Un stream así **también falla en `loads()`**,
  nunca al revés — se probó explícitamente que ambos backends coinciden en esto.
- `reporte.ok == True and reporte.fully_verified == True` → el árbol completo es
  dato puro (números, strings, listas, dicts, bytes, sets...). No hay ningún
  opcode que vaya a ejecutar código si se llama `loads()` sobre este mismo stream.
- `reporte.ok == True and reporte.fully_verified == False` → el stream está bien
  formado, pero contiene al menos un bloque que solo se puede confirmar
  ejecutándolo. **`verify_stream()` no dice si ese código es seguro** — solo dice
  que existe y en qué posición (`reporte.unexecuted_blocks`). Decidir si ejecutarlo
  vía `loads()` sigue siendo responsabilidad de quien llama.

Es decir: `verify_stream()` responde "¿este archivo va a intentar ejecutar algo, y
si no, puedo confiar en su estructura?" — no responde "¿es seguro este archivo?" en
términos absolutos, porque esa pregunta no tiene respuesta posible para un formato
que soporta código ejecutable por diseño.

**Verificado:**
- 300 estructuras anidadas aleatorias (números, floats, strings, bytes, listas,
  dicts, tuplas, sets, profundidad variable) → `fully_verified=True` en el 100%,
  roundtrip con `loads()` exacto en el 100%, en **ambos backends**.
- 151+ streams (aleatorios + con clases/funciones/`__reduce__` mezclados) comparados
  campo por campo entre backend C y Python → **0 discrepancias**.
- Confirmado con un espía en `__new__` que `verify_stream()` no ejecuta ningún
  código real, ni siquiera para el `__reduce__` más simple.
- Índices de referencia fuera de rango se detectan como corrupción real
  (`reporte.ok=False`), consistente con que `loads()` también falla ahí.
- Sin fugas de referencias detectadas en el motor C tras miles de iteraciones en
  el camino de éxito y en el camino de error (conteo de objetos vivos estable).
- Límite de anidamiento (200 niveles) se activa correctamente sin desbordar la
  pila del proceso, en ambos backends.
- Tras el refactor de dispatch (bug #11): `verify_stream()` sigue reportando
  correctamente sobre streams con dedup de dict/list (`0x13`/`0x14`), incluyendo el
  índice de dedup correcto para referencias posteriores a esas entradas.

**Disponible en ambos motores**, con la misma API y el mismo tipo de resultado
(`compickle.StreamReport`) sin importar cuál esté activo:

```python
compickle.backend()          # → 'c' o 'python'
compickle.verify_stream(datos)  # devuelve StreamReport en ambos casos, idéntico
```

### `show_source=True` — leer el código sin ejecutarlo

Por defecto, `reporte.unexecuted_blocks` te dice *dónde* está el código y *por qué*
requiere ejecución, pero no *qué dice* ese código. `verify_stream(data,
show_source=True)` agrega ese texto — para que la persona que llama pueda leerlo y
decidir, en vez de que `verify_stream()` decida por ella.

```python
reporte = compickle.verify_stream(datos_no_confiables, show_source=True)

for pos, tag, motivo, codigo in reporte.unexecuted_blocks:
    print(f"--- offset {pos}: {motivo} ---")
    print(codigo)
```

Qué contiene `codigo` según el tipo de bloque:

- **Clases e instancias (`0x1C`/`0x1E`), funciones vía fuente (`0x0D`),
  `__reduce__` con fuente embebida (`0x1F`)**: el código fuente Python real,
  capturado desde el propio stream (descomprimiendo zlib si aplica) y decodificado
  como UTF-8 — texto legible, no una aproximación.
- **Funciones y code objects vía `marshal` (`0x20`/`0x21`)**: estos NO tienen fuente
  Python guardada — solo bytecode compilado. `codigo` trae el **desensamblado**
  (`dis.dis()`) de ese bytecode: instrucciones legibles, no el texto fuente original
  (que no existe en el stream).

**Por qué esto es seguro — verificado explícitamente, no solo argumentado:**

Extraer y mostrar este texto es lectura/decodificación pura. Ninguno de los pasos
involucrados (descomprimir zlib, decodificar UTF-8, `marshal.loads()` sobre el
blob, `dis.dis()` sobre el code object resultante) ejecuta la función o clase que
describen — `marshal.loads()` sobre bytes solo reconstruye una estructura de datos
(constantes, nombres, instrucciones), no invoca nada, y `dis.dis()` solo lee esa
estructura y la formatea como texto. Esto se confirmó con una función cuyo cuerpo
llama `os._exit(1)` (terminaría el proceso entero si se ejecutara): pasarla por
`verify_stream(..., show_source=True)` no lo dispara, el desensamblado se genera
igual, y el proceso sigue vivo.

**Lo que `show_source=True` NO te da — para que no se lea de más:**

Leer el código y decidir que "se ve bien" es juicio humano, no una garantía
mecánica. `verify_stream()` no analiza si el código es dañino — un fragmento que se
ve trivial puede seguir haciendo algo dañino si después se ejecuta vía `loads()`.
`show_source=True` te da la información para decidir; no toma la decisión por vos.

**Nota de robustez (distinta de "ejecuta código Python"):** la documentación de
CPython advierte que el formato `marshal` en sí no está diseñado para ser resistente
a datos adversariales a nivel del propio parser de C. Esto es un riesgo de bajo
nivel del parser, no de "el código Python se ejecuta" (eso no ocurre, confirmado
arriba) — pero tampoco es una garantía absoluta de robustez ante bytes construidos
específicamente para explotar el parser de `marshal` de una versión dada de CPython.

**Cambio de formato (rompe compatibilidad con `1.2.1`):** cada entrada de
`unexecuted_blocks` pasó de ser `(pos, tag, motivo)` a `(pos, tag, motivo, codigo)`.
`codigo` es `None` cuando se llama sin `show_source=True` — el resto de los campos
del reporte (`ok`, `fully_verified`, `bytes_total`, `bytes_consumed`, `tag_counts`)
es idéntico con o sin esta opción. Código que hacía
`for pos, tag, motivo in reporte.unexecuted_blocks` necesita el cuarto valor ahora.

---

## ⚙️ Instalación

`compickle` es un paquete real (`compickle/__init__.py` + `compickle/compickle.py` +
`compickle/compickle.c`), no un módulo suelto. La extensión C (`compickle._compickle`)
se compila e instala **dentro** de la carpeta del paquete al hacer `pip install`.

```bash
# Desde el directorio raíz del proyecto (donde está pyproject.toml)
pip install .

# O en modo editable, para desarrollo
pip install -e .
```

Esto compila `compickle/compickle.c` y coloca el `.so` resultante en
`compickle/_compickle.cpython-*.so`, junto a `__init__.py` — necesario para que el
`from ._compickle import ...` (import relativo) de `__init__.py` funcione.

**Si la compilación falla o `_compickle.so` no está presente por cualquier motivo**,
`compickle` usa el fallback puro Python de forma automática y silenciosa — no hace
falta configurar nada distinto en el código que lo consume. `compickle.backend()`
dice cuál de los dos está activo en cualquier momento.

Verificado explícitamente: instalación limpia vía `pip install .` en un entorno
aislado, confirmando que el `.so` compilado cae dentro de `site-packages/compickle/`
y que `dumps`/`loads`/`verify_stream`/`backend`/`dedup_reset` funcionan correctamente
contra esa instalación real (no solo contra el árbol de código fuente).

---

## 🚀 Uso rápido

```python
import compickle

datos = {
    "nombre": "Rex",
    "edad": 5,
    "activo": True,
    "coordenadas": (4.0, 2.0),
    "etiquetas": {"perro", "mascota"},
}

# Serializar a archivo
compickle.dump(datos, "datos.cpkl")

# Deserializar desde archivo
copia = compickle.load("datos.cpkl")

# Serializar/deserializar en memoria (bytes)
raw = compickle.dumps(datos)
copia2 = compickle.loads(raw)

# Ver qué motor está activo
print(compickle.backend())  # → 'c' o 'python'
```

---

## 📖 API completa

### `compickle.dump(obj, path)`

Serializa `obj` y escribe el resultado binario en `path`.

```python
compickle.dump(mi_objeto, "salida.cpkl")
```

### `compickle.dumps(obj) → bytes`

Serializa `obj` y devuelve los bytes resultantes directamente. Con el motor C, cada
llamada crea su propio `DedupState` en el stack — no hace falta llamar
`dedup_reset()` antes de cada `dumps()`.

```python
raw: bytes = compickle.dumps(mi_objeto)
```

### `compickle.load(path) → object`

Lee el archivo binario en `path` y reconstruye el objeto original.

```python
obj = compickle.load("salida.cpkl")
```

### `compickle.loads(data: bytes) → object`

Deserializa directamente desde un objeto `bytes`.

```python
obj = compickle.loads(raw_bytes)
```

### `compickle.dedup_reset()`

Limpia los cachés de `source`/`exec`/clases del backend Python, y `source_cache`/
`exec_cache`/`class_header_cache` del motor C. **No limpia `cls_cache` en el motor
C** — ver la nota de "pendiente de decisión" en la sección de bugs más arriba. El
`DedupState` de serialización es local por llamada y no requiere reseteo manual —
esta función es para liberar memoria en procesos de larga duración que hayan
serializado muchas clases o funciones distintas.

```python
compickle.dedup_reset()
```

### `compickle.verify_stream(data: bytes, show_source: bool = False) → StreamReport`

*Nuevo en `1.2.1`, extendido en `1.2.2` con `show_source`.* Camina la estructura de
`data` sin deserializar de verdad — nunca ejecuta código, nunca construye objetos.
Ver la sección [🔒 `verify_stream()`](#-verify_stream--inspección-sin-ejecución)
arriba para el comportamiento completo, incluida la nota de seguridad de
`show_source=True`.

```python
reporte = compickle.verify_stream(datos_no_confiables, show_source=True)
reporte.ok               # bool: ¿estructura válida?
reporte.fully_verified   # bool: ¿válida Y sin ningún bloque que requiera ejecución?
reporte.error             # str | None: motivo si ok es False
reporte.unexecuted_blocks # list[(pos, tag, motivo, codigo)]: bloques que requieren ejecución
                           # codigo es None salvo que show_source=True
reporte.tag_counts        # dict[int, int]: conteo de cada opcode visto
reporte.bytes_total        # int
reporte.bytes_consumed     # int
```

### `compickle.backend() → str`

Devuelve el motor activo.

```python
compickle.backend()  # → 'c' o 'python'
```

---

## 🧩 Tipos soportados

| Tipo Python | Tag (escritura actual) | Deduplicado | Notas |
|---|---|:---:|---|
| `None` | `0x00` | — | 1 byte |
| `False` / `True` | `0x80` / `0x81` | — | 1 byte |
| `int` (0–58) | `0xC0–0xFA` | — | 1 byte: opcode directo `0xC0 + valor` |
| `int` (59–255) | `0x11` | — | 2 bytes: tag + `uint8` |
| `int` (-1..-30) | `0x10` | — | 2 bytes |
| `int` (-31..-256) | `0x12` | — | 2 bytes |
| `int` (256–65535) | `0x0F` | — | 3 bytes: tag + `uint16` big-endian |
| `int` (arbitrario) | `0x02` | — | signo(1) + longitud + bytes big-endian |
| `float` (0.0 / -0.0 / 1.0 / -1.0 / NaN / ±inf) | `0x82`–`0x88` | — | 1 byte cada uno |
| `float` (general) | `0x03` | — | 9 bytes: tag + IEEE 754 doble precisión |
| `complex` | `0x04` | — | 17 bytes: tag + 2× `float64` |
| `str` | `0x05` / `0x15` | ✅ (identidad + contenido) | `0x15` para strings nuevas ≤63 bytes UTF-8 |
| `bytes` | `0x06` | ✅ (identidad + contenido) | Por contenido |
| `bytearray` | `0x07` | ✅ (identidad + contenido) | Por contenido |
| `list` | `0x14` | ✅ (identidad de puntero) | Recursivo. `0x08` sigue existiendo como formato de **lectura** para streams generados por versiones anteriores; el escritor actual nunca lo emite para listas de nivel de datos |
| `tuple` | `0x09` | — | Recursivo. Sin dedup de identidad (a diferencia de `list`) |
| `set` | `0x0A` | — | Ordenado por `repr()` para determinismo |
| `frozenset` | `0x0B` | — | Ordenado por `repr()` para determinismo |
| `dict` | `0x13` | ✅ (identidad de puntero) | Recursivo en claves y valores. `0x0C` sigue existiendo, tanto como formato de lectura para streams antiguos, como formato de **escritura actual** para el `__dict__`/`__slots__` interno de instancias (ver nota abajo) |
| `function` / `lambda` | `0x20` | ✅ (bytecode) | Vía `marshal`: code object + defaults + freevars |
| Generador / corutina | `0x14` | — | Se consume y materializa como lista (con dedup de identidad de la lista resultante) |
| `types.CodeType` | `0x21` | ✅ | Vía `marshal` directo |
| `type` (clase) | *(sin tag propio; ver nota)* | ✅ (fuente, vía `0x1C`/`0x1E`) | Nombre + módulo + `inspect.getsource`, emitido como parte del header de clase dentro de `0x1C`/`0x1E` |
| Instancia con `__dict__` | `0x1C` | ✅ (fuente) | Encabezado de clase + `__dict__` (este último con tag `0x0C`, sin dedup de identidad — ver nota) |
| Instancia con `__slots__` | `0x1E` | ✅ (fuente) | Recorre el MRO completo |
| Objeto con `__reduce__` | `0x1F` | ✅ | Tuplas de 2 a 5 elementos |
| `classmethod` | `0x22` | ✅ (bytecode) | Envuelve el `__func__` interno (tag `0x20`) |
| `staticmethod` | `0x23` | ✅ (bytecode) | Envuelve el `__func__` interno (tag `0x20`) |
| `property` | `0x24` | ✅ (bytecode) | `fget`/`fset`/`fdel`, cualquiera puede ser `None` |

> **Sobre `dict`/`list` de dedup y el `__dict__` interno de instancias:** el dedup de
> identidad nuevo (`0x13`/`0x14`) se aplica a dicts/listas de **nivel de datos**
> (los que el usuario serializa directamente o anida dentro de otras estructuras) —
> no al `__dict__` propio de cada instancia (siempre único por instancia, nunca
> compartido por identidad entre instancias distintas en el caso normal) ni al dict
> temporal de valores de `__slots__` (creado nuevo en cada llamada). Esos dos casos
> siguen usando `0x0C` sin dedup a propósito, porque el chequeo de identidad ahí no
> encontraría hits reales y solo agregaría overhead. Ver bug #10 más arriba para el
> razonamiento completo.
>
> **Sobre el tag `0x12` y `type`/clase:** en versiones anteriores del formato, `0x12`
> tuvo una rama de lectura para "class object" standalone. Esa rama era código
> muerto (inalcanzable, ver bug #11) y se removió — `0x12` es exclusivamente
> "entero negativo -31..-256" en el código actual. La serialización de una clase
> como tal (nombre + módulo + fuente) ocurre como parte del header emitido al
> principio de los bloques `0x1C`/`0x1E`, no como un opcode de nivel superior
> independiente.

**Bound method (`types.MethodType`) no tiene tag propio.** Tiene `__reduce__`
heredado de la clase builtin `method`, así que ya cae en `0x1F` (`__reduce__`,
detectado antes de que se evalúe ningún tag dedicado) y reconstruye correctamente
`__func__` + `__self__` — no requiere un opcode dedicado.

**Prioridad de serialización de instancias:** `__reduce__` personalizado en el MRO →
`0x1F`; si no, `classmethod`/`staticmethod`/`property` por tipo exacto → `0x22`/
`0x23`/`0x24`; si no, `__dict__` disponible → `0x1C`; si no, `__slots__` sin
`__dict__` → `0x1E`. El chequeo de `classmethod`/`staticmethod`/`property` va
**antes** de la rama genérica `__dict__` a propósito: los tres SÍ tienen `__dict__`
propio (heredado de `object`, con metadata trivial como `__name__`/`__doc__`), así
que si cayeran en la rama genérica se intentaría `inspect.getsource()` sobre la
clase builtin en sí — que falla con `"<class 'X'> is a built-in class"` — en vez de
sobre la función envuelta, que es lo que de verdad hace falta preservar.

**Nota sobre clases/funciones reconstruidas:** al deserializar vía fuente (`0x0D`,
`0x1C`, `0x1E`), `compickle` no recupera el objeto `type`/`function` original —
ejecuta el código fuente capturado en un espacio de nombres nuevo y devuelve una
clase/función equivalente por comportamiento, pero distinta por identidad (`is`).
Al deserializar vía `marshal` (`0x20`/`0x21`), el bytecode se reconstruye
directamente sin re-ejecutar fuente, pero el objeto resultante tampoco es idéntico
por identidad al original (es una nueva instancia de `function`/`code` construida a
partir del bytecode). Verificado comparando por contenido/comportamiento, no por
identidad de objeto, en ambos casos.

---

## 🔬 Cómo funciona internamente

### Motor C (`compickle/compickle.c`)

**Buffer de salida (`Buf`).** Arranca en 256 bytes y se duplica vía `realloc` cuando
hace falta.

**Arena allocator.** Bloque contiguo para los datos de cada entrada de dedup —
`reset` es mover un puntero, sin `malloc`/`free` por entrada.

**`DedupState` local por llamada.**

```c
typedef struct {
    Arena    arena;
    DEntry  *table;
    int      count, cap;
    int     *buckets;
    int      nbuckets;
    IdEntry *id_tab;
    int      id_nb, id_count;
} DedupState;
```

Se declara como variable local dentro de `py_serialize_fast()` (`dedup_init(&ds)` al
entrar, `dedup_destroy(&ds)` al salir) — no hay estado global de dedup entre llamadas
distintas. Arranca en `DEDUP_CAP_INIT = 256` entradas y rehashea automáticamente
cuando el factor de carga supera `HASH_LOAD_MAX = 0.65`.

**`id_tab` (shortcut por identidad de puntero).** Antes de calcular el hash FNV-1a
del contenido, se busca `id(obj)` en `id_tab` — si hay hit, se emite la referencia
directamente sin volver a hashear ni comparar bytes. Este mismo mecanismo es la base
del dedup de `dict`/`list` (tags `0x13`/`0x14`): a diferencia de `str`/`bytes`, estos
contenedores se deduplican **únicamente** por identidad de puntero, nunca por
contenido — comparar el contenido completo de un dict/lista en cada aparición sería
más caro que el ahorro que se busca.

**Tabla hash FNV-1a.** Hash de 32 bits sobre el `tag` de tipo más los bytes del dato;
colisiones resueltas con listas enlazadas. Las entradas de dedup de identidad pura
(`dict`/`list`) reservan un índice en esta tabla pero deliberadamente **no** se
enlazan en ningún bucket — así una búsqueda por contenido nunca puede encontrarlas
por accidente.

**Codificación de longitud variable:**

```
n ≤ 0x3F     → 1 byte
n ≤ 0x3FFF   → 2 bytes  (0x40 | n>>8, n & 0xFF)
n > 0x3FFF   → 5 bytes  (0xFF + uint32 big-endian)
```

**Referencias dedup:**

```
idx ≤ 0xFE     → [0xFE][idx_1byte]      (2 bytes)
idx ≤ 0xFFFF   → [0xFD][idx_2bytes_be]  (3 bytes)
idx > 0xFFFF   → [0xFC][idx_4bytes_be]  (5 bytes)
```

Si hay hit de dedup, **no se reescribe el type tag** — solo la referencia. Este es
justamente el mecanismo donde vivía el bug #5 de la sección anterior: registrar un
índice sin emitir nada al stream para él desincronizaba las referencias siguientes.

**`read_table` dinámico.** Arranca en 256 entradas y crece con `realloc`. Para
`dict`/`list` con dedup de identidad, la entrada se registra en `read_table`
**antes** de leer su contenido — necesario para que una referencia posterior, o una
auto-referencia (un dict/lista que se contiene a sí mismo), resuelva al mismo
objeto en construcción en vez de recursar sin fin.

**Cachés globales (persisten entre llamadas, a diferencia del `DedupState` que es
por-llamada):**
- `source_cache` / `exec_cache`: mapean objeto → fuente capturada, y fuente →
  namespace ya ejecutado, para no repetir I/O ni volver a `exec()` el mismo código.
- `class_header_cache`: mapea `id(cls)` → `(tag, payload)` ya decidido (comprimido o
  no con zlib) — evita recomprimir la fuente de la misma clase en cada instancia
  (ver bug #9).
- `cls_cache`: mapea `(nombre, fuente)` → clase ya resuelta.
- `marshal_module_cached` / `builtins_module_cached`: referencias al módulo
  `marshal` y `builtins`, obtenidas una vez con `PyImport_ImportModule` y
  reutilizadas — evita el overhead de reimportar en cada función/code-object
  serializado o deserializado.
- `verify_stream_compickle_mod_cached` / `streamreport_cls_cached`: referencias al
  propio submódulo `compickle.compickle` y a la clase `StreamReport`, para no
  reimportar ni rebuscar el atributo en cada llamada a `verify_stream()`.

### Fallback Python (`compickle/compickle.py`)

Implementa el mismo protocolo en Python puro:

- Deduplicación con `_dedup_id: dict[int, int]` (shortcut por identidad) y
  `_dedup_cnt: dict[tuple[int, bytes], int]` (por contenido, fusionados con
  `setdefault` para reducir accesos al dict). `dict`/`list` de nivel de datos usan
  únicamente `_dedup_id` (identidad de puntero, nunca contenido) a través de
  `_ser_dict_deduped`/`_ser_list` — funciones separadas de `_ser_dict`/`_ser_tuple`,
  que siguen sin dedup para el `__dict__` interno de instancias y para `tuple`
  respectivamente.
- `_REF1`: tabla precalculada de referencias de 1 byte de índice.
- Serialización acumulada en `bytearray`; deserialización sobre `memoryview` sin
  copia.
- `_DISPATCH: dict[type, Callable]` para despacho por tipo exacto en el lado de
  escritura, evitando la cascada de `isinstance` en el caso común. Para el
  fallback de tipos no built-in (`_serialize_slow`), un chequeo único
  `isinstance(obj, _BUILTIN_CONTAINER_TYPES)` (una tupla de 10 tipos) precede a la
  cascada de `isinstance` individuales — así una instancia de clase de usuario
  (que no es subclase de ningún tipo built-in) se descarta con una sola llamada en
  vez de diez.
- `_deserialize` resuelve inline, en el propio bucle de listas/dicts, los 4 tags más
  frecuentes (entero pequeño, referencia, string corta, entero de 1 byte) antes de
  considerar el resto — optimización de una ronda anterior, sigue vigente. Para el
  resto de los tags, `_TAG_HANDLERS: dict[int, Callable]` reemplaza lo que antes era
  una cadena de hasta 39 comparaciones secuenciales por un dispatch O(1).

---

## 🧪 Ejemplos avanzados

### Clase con `__dict__`

```python
import compickle

class Punto:
    def __init__(self, x, y, etiqueta='sin etiqueta'):
        self.x = x
        self.y = y
        self.etiqueta = etiqueta

    def distancia_origen(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

p = Punto(3, 4, 'origen')
raw = compickle.dumps(p)
p2 = compickle.loads(raw)
print(p2.x, p2.y, p2.etiqueta)      # → 3 4 origen
print(p2.distancia_origen())        # → 5.0
```

### Clase con `__slots__`

```python
class Vector:
    __slots__ = ("x", "y", "z")
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

v = Vector(1, 2, 3)
raw = compickle.dumps(v)
v2 = compickle.loads(raw)
print(v2.x, v2.y, v2.z)             # → 1 2 3
```

### Múltiples instancias de varias clases mezcladas

```python
class Alpha:
    def __init__(self, v):
        self.v = v

class Beta:
    def __init__(self, w, z):
        self.w = w
        self.z = z

payload = {
    "alphas": [Alpha(1), Alpha(2), Alpha(3)],
    "mixed": [Alpha(10), Beta(5, 6), Alpha(11)],
}
raw = compickle.dumps(payload)
back = compickle.loads(raw)
print([a.v for a in back["alphas"]])           # → [1, 2, 3]
print(back["mixed"][1].w, back["mixed"][1].z)  # → 5 6
```

### Deduplicación en acción

```python
repetidos = ["usuario_activo"] * 1000
raw = compickle.dumps(repetidos)
print(len(raw))   # ≈2000 bytes, no 1000× el tamaño del string
```

Esto también aplica a estructuras compuestas compartidas por identidad, no solo a
strings — ver bug #10 más arriba:

```python
perfil = {"pais": "MX", "nivel": "premium"}
usuarios = [{"id": i, "perfil": perfil} for i in range(50_000)]
raw = compickle.dumps(usuarios)
# perfil se emite UNA vez; las 49 999 repeticiones restantes son
# referencias de ~2 bytes cada una, no el dict completo repetido.
```

### Objeto con `__reduce__`

```python
class Color:
    def __init__(self, r, g, b):
        self.r, self.g, self.b = r, g, b

    def __reduce__(self):
        return (Color, (self.r, self.g, self.b))

c = Color(255, 128, 0)
raw = compickle.dumps(c)
c2 = compickle.loads(raw)
print(c2.r, c2.g, c2.b)   # → 255 128 0
```

### Lambda y funciones vía `marshal`

```python
fn = lambda x: x * 2
raw = compickle.dumps(fn)
fn2 = compickle.loads(raw)
print(fn2(21))   # → 42
```

### `classmethod`, `staticmethod` y `property`

```python
class Configuracion:
    valor_por_defecto = 10

    @classmethod
    def desde_entorno(cls, nombre):
        return cls(nombre, cls.valor_por_defecto)

    @staticmethod
    def validar(nombre):
        return len(nombre) > 0

    def __init__(self, nombre, valor):
        self.nombre = nombre
        self._valor = valor

    @property
    def valor(self):
        return self._valor

    @valor.setter
    def valor(self, nuevo):
        if nuevo < 0:
            raise ValueError("no puede ser negativo")
        self._valor = nuevo

# Cada uno se serializa por separado, tomado de Configuracion.__dict__
cm = Configuracion.__dict__["desde_entorno"]
raw_cm = compickle.dumps(cm)
cm2 = compickle.loads(raw_cm)
cfg = cm2.__get__(None, Configuracion)("produccion")
print(cfg.nombre, cfg.valor)   # → produccion 10

sm = Configuracion.__dict__["validar"]
raw_sm = compickle.dumps(sm)
sm2 = compickle.loads(raw_sm)
print(sm2.__get__(None, Configuracion)("produccion"))   # → True

prop = Configuracion.__dict__["valor"]
raw_prop = compickle.dumps(prop)
prop2 = compickle.loads(raw_prop)

class Reconstruida:
    valor = prop2
    def __init__(self, v):
        self._valor = v

r = Reconstruida(5)
print(r.valor)      # → 5
r.valor = 20
print(r.valor)      # → 20
```

### Inspeccionar un archivo no confiable antes de cargarlo

> Nota: como con cualquier clase serializada por `compickle` (ver
> [Limitaciones conocidas](#️-limitaciones-conocidas)), `Config` necesita estar
> definida en un archivo real para que `inspect.getsource()` pueda leerla — no
> funciona pegando este bloque directo en el REPL o en `python -c`.

```python
import compickle

# Caso 1: datos puros -- llega de una API externa, sin clases ni funciones
datos_api = {"usuario": "ana", "puntos": [10, 25, 40], "activo": True}
raw = compickle.dumps(datos_api)

reporte = compickle.verify_stream(raw)
print(reporte.fully_verified)   # → True: nada que ejecutar, seguro llamar loads()

# Caso 2: un archivo que sí trae una clase serializada
class Config:
    def __init__(self, modo):
        self.modo = modo

raw_con_clase = compickle.dumps({"config": Config("produccion"), "version": 3})

reporte2 = compickle.verify_stream(raw_con_clase)
print(reporte2.ok)               # → True: la estructura es válida
print(reporte2.fully_verified)   # → False: hay un bloque que requiere ejecución
for pos, tag, motivo, _ in reporte2.unexecuted_blocks:
    print(f"offset {pos}: 0x{tag:02X} — {motivo}")
# → offset <pos>: 0x1C — instancia: clase materializada vía exec() de su fuente

# Caso 3: lo mismo, pero leyendo el código real antes de decidir
reporte3 = compickle.verify_stream(raw_con_clase, show_source=True)
for pos, tag, motivo, codigo in reporte3.unexecuted_blocks:
    print(f"offset {pos}: {motivo}")
    print(codigo)
# → offset <pos>: instancia: clase materializada vía exec() de su fuente
#   # clase: Config
#   class Config:
#       def __init__(self, modo):
#           self.modo = modo

# verify_stream() no decide por vos si ese bloque es seguro -- ni siquiera
# con show_source=True. Solo te deja ver qué hay ahí para que decidas vos
# si llamar loads() sobre él.
```

---

## 📦 Formato binario — tabla completa de opcodes

```
Opcode        Tipo / Significado
──────────────────────────────────────────────────────────────────────────
0x00          None
0x02          int arbitrario: signo(1) + longitud + bytes big-endian
0x03          float general: 8 bytes IEEE 754 big-endian
0x04          complex: 2 × float64 big-endian (16 bytes)
0x05          str: tag + dedup(UTF-8) [strings largas o referencias]
0x06          bytes: tag + dedup(contenido)
0x07          bytearray: tag + dedup(contenido)
0x08          list SIN dedup de identidad: len(n) + n × serialize(item)
              [solo lectura -- formato previo a 0x14, ver tabla de tipos]
0x09          tuple: len(n) + n × serialize(item) [sin dedup de identidad]
0x0A          set: len(n) + n × serialize(item ordenado por repr())
0x0B          frozenset: len(n) + n × serialize(item ordenado por repr())
0x0C          dict SIN dedup de identidad: len(n) + n × (serialize(k) + serialize(v))
              [escritura actual para __dict__/slots internos de instancias;
               lectura también para streams previos a 0x13]
0x0E          dedup corto (nuevo): longitud(1 byte, ≤63) + datos
0x0F          int 256–65535: 2 bytes uint16 big-endian
0x10          int negativo pequeño -1..-30: [0x10][magnitud-1]
0x11          int 59–255: [0x11][valor]
0x12          int negativo pequeño -31..-256: [0x12][magnitud-1]
0x13          dict CON dedup de identidad: id_tab_find/insert + len(n) +
              n × (serialize(k) + serialize(v)) -- tag nuevo, ver bug #10
0x14          list CON dedup de identidad: id_tab_find/insert + len(n) +
              n × serialize(item) -- tag nuevo, ver bug #10
0x15          str corta nueva (≤63 bytes UTF-8): [0x15][len][datos]
0x1B          class source comprimida con zlib (dentro de dedup)
0x1C          instancia __dict__: class_header + serialize(__dict__ vía 0x0C)
0x1D          class source sin comprimir (dentro de dedup)
0x1E          instancia __slots__: class_header + serialize(dict de slots vía 0x0C)
0x1F          instancia __reduce__: callable_ref + args + flags + [state] + [list_items] + [dict_items]
0x20          función/lambda vía marshal: code + defaults + freevars
0x21          types.CodeType vía marshal directo
0x22          classmethod: envuelve __func__ (tag 0x20 interno)
0x23          staticmethod: envuelve __func__ (tag 0x20 interno)
0x24          property: fget + fset + fdel (cualquiera puede ser 0x00/None)
0x80 / 0x81   False / True
0x82–0x88     float: +0.0 / -0.0 / 1.0 / -1.0 / NaN / +inf / -inf
0xC0–0xFA     int pequeño positivo (valor = opcode − 0xC0, rango 0–58)
0xFB          dedup largo (nuevo): tag(1) + longitud + datos (>63 bytes)
0xFC          referencia dedup: 4 bytes big-endian de índice
0xFD          referencia dedup: 2 bytes big-endian de índice
0xFE          referencia dedup: 1 byte de índice
```

---

## ⚠️ Limitaciones conocidas

- **Clases y callables de `__reduce__` requieren fuente accesible:**
  `inspect.getsource()` debe poder leer el código original. No funciona con clases
  definidas en el REPL o vía `exec()`/`python -c` sin archivo de respaldo. Funciones
  normales y lambdas sí funcionan en el REPL vía `marshal`.
- **Funciones no portables entre versiones de Python:** el bytecode serializado con
  `marshal` está ligado al magic number de la versión de CPython que lo generó.
- **Generadores se consumen:** serializar un objeto generador ya instanciado lo
  materializa en lista y lo agota. Para preservar la capacidad de generar, serializar
  la función generadora en sí, no su resultado.
- **No compatible con `pickle`:** formato binario propio, no intercambiable con
  `pickle`, `marshal` u otros serializadores estándar.
- **Referencias circulares:** soportadas para `dict`/`list` (tags `0x13`/`0x14`,
  desde la ronda de dedup de contenedores) — un dict o lista que se contiene a sí
  mismo, directa o indirectamente, deserializa correctamente con el ciclo
  preservado. **No auditado** para otros tipos que también podrían formar ciclos
  (por ejemplo, un objeto con `__reduce__` cuyo `state` lo referencia a sí mismo, o
  instancias con `__dict__` que se referencian circularmente entre sí).
- **`verify_stream()` no es un sandbox ni un antivirus.** Confirma estructura
  (tags válidos, longitudes consistentes, referencias en rango) y te dice
  exactamente qué bloques requerirían ejecutar código si llamaras `loads()` —
  pero no analiza ni juzga si ese código sería dañino. Un stream con
  `fully_verified=False` puede contener una clase totalmente inofensiva o
  una maliciosa; `verify_stream()` no distingue entre ambas, solo señala
  dónde está el código que tendrías que confiar en ejecutar.
- **`reason` de `property` difiere ligeramente entre backends.** Con
  `show_source=True`, `code_text` es idéntico byte a byte entre C y Python (ambos
  usan la misma función de render). Pero el campo corto `reason` (visible incluso
  sin `show_source`) dice `"envuelve fget, fset"` en Python (específico) y
  `"envuelve accesor(es)"` en C (genérico) — decisión deliberada para evitar un
  buffer `static` reusado en la recursión de C que habría corrompido `reason` al
  serializar múltiples `property` en el mismo stream. Si el texto corto exacto de
  `reason` importa para tu caso de uso, usa `code_text` (con `show_source=True`),
  que sí es consistente entre ambos backends.
- **`dedup_reset()` no limpia `cls_cache` en el motor C** — ver la nota de
  "pendiente de decisión" en la sección de bugs.
- **Dedup de identidad limitado a `dict`/`list`.** `tuple`/`set`/`frozenset`
  compartidos por identidad siguen re-serializándose completos en cada aparición,
  igual que antes de la ronda de dedup de contenedores — decisión deliberada, no
  bug: extenderlo a esos tipos no se hizo en esta ronda.

---

## 📄 Licencia

MIT — úsalo como quieras.
```

---

Cambios de fondo respecto a tu versión, para que los tengas mapeados sin tener que releer todo:

1. **Tabla de tipos**: `list` era `0x08`, ahora `0x14`. `dict` era `0x0C`, ahora `0x13` (con nota de que `0x0C` sigue vivo para uso interno). `class` tenía `0x12` propio — no existe como opcode independiente, se quitó esa fila y se explica dónde va la fuente de clase en realidad.
2. **Tabla de opcodes completa**: agregadas las filas `0x13`/`0x14`, corregida la descripción de `0x08`/`0x0C`/`0x12`.
3. **`verify_stream`**: decía "cinco" opcodes que ejecutan código, son nueve — corregido con la lista completa.
4. **Cachés del motor C**: documentaba 2, hay 8 — agregué los 6 que faltaban con una línea cada uno.
5. Agregué los bugs #9, #10, #11 con la misma fidelidad de detalle que ya tenías en el #1-8, más la nota de `cls_cache` pendiente, más la sección de rendimiento nueva, todo separado y marcado como "de esta ronda" para no mezclarlo con lo anterior.
6. Actualicé "limitaciones conocidas" con lo de referencias circulares (ahora parcialmente soportado) y la limitación de que tuple/set no tienen dedup de identidad.

No toqué nada de lo que verifiqué que sigue siendo cierto (instalación, ejemplos de código, `show_source`, la nota de `reason` distinto entre backends) — corrí cada ejemplo contra el código real antes de decidir si tocarlo o no.
