Metadata-Version: 2.4
Name: compickle
Version: 1.3.5
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.3.0-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 completa (`dumps`, `loads`, `dump`, `load`, `verify_stream`,
`dedup_reset`, `backend`, `StreamReport`, `UnsafeStreamError`) — 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`. **[1.3.0]:** antes de esta versión, `compickle.compickle`
tenía su propia copia paralela y nunca usada de gran parte de esta API — ver la
nota de simplificación al principio de [📖 API completa](#api-completa).

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

---

## 📊 Estado medido (honesto)

### Benchmark de 1.3.0: compickle vs pickle, cloudpickle, dill (11 cargas de trabajo)

Medido en esta sesión, contra los tres paquetes reales (no simulados ni estimados):
`pickle` (stdlib, protocolo 5), `cloudpickle` 3.1.2, `dill` 0.4.1. 11 cargas de
trabajo elegidas para incluir tanto casos favorables a compickle (alta redundancia
estructural, muchas instancias de una clase) como casos donde no se espera ninguna
ventaja (bytes aleatorios sin redundancia, listas de enteros densas) — el objetivo
fue medir el perfil real, no maximizar el número más favorable.

**Metodología:** 9 repeticiones por combinación, mediana reportada (no media — más
robusta a outliers de scheduling en el entorno de un solo núcleo donde se corrió
esto). Tiempo medido con `time.process_time()` (CPU del proceso, no reloj de pared)
tras confirmar empíricamente que con pocas repeticiones el ruido del entorno daba
lecturas engañosas — un caso llegó a mostrar "6x más lento" con 1-3 repeticiones y
resultó ser ~1.27x con muestra de 20. Antes de confiar en cualquier tiempo, se
verificó que las 44 combinaciones (11 cargas × 4 motores) produjeran un roundtrip
correcto — un motor más rápido produciendo un resultado incorrecto no cuenta como
"más rápido" en esta tabla.

| carga de trabajo | n | bytes compickle/pickle | serialize (compickle vs pickle) | deserialize (compickle vs pickle) |
|---|---|---|---|---|
| enteros densos, sin redundancia | 200 000 | 1.19x (más grande) | 0.97x (empate) | 0.75x (más lento) |
| strings repetidos (200 valores únicos) | 100 000 | 0.998x (empate) | **1.57x más rápido** | **1.13x más rápido** |
| dicts anidados, profundidad 4 | 6 000 | **0.87x (más chico)** | 0.99x (empate) | **1.02x más rápido** |
| tuplas mixtas | 100 000 | **0.86x (más chico)** | **2.11x más rápido** | **1.06x más rápido** |
| estructura anidada grande (1 objeto) | 50×50 | **0.94x (más chico)** | 0.58x (más lento) | **1.12x más rápido** |
| muchos dicts con 2 campos compartidos por identidad | 3 000 | **0.80x (más chico)** | 0.25x (**4x más lento**) | 0.95x (empate) |
| mismo caso, SIN compartir (control) | 3 000 | 0.95x (más chico, menor margen)¹ | 0.90x (empate/algo más lento)¹ | 1.11x (más rápido)¹ |
| bytes aleatorios (incompresibles) | 2 000 | 1.000x (empate exacto) | 0.58x (más lento) | 1.01x (empate) |
| estructuras auto-referenciales + ciclo | 502 | **0.78x (más chico)** | **1.31x más rápido** | **1.43x más rápido** |
| 50 000 instancias, `__slots__` | 50 000 | **0.86x (más chico)** | 0.53x (más lento) | 1.00x (empate) |
| 50 000 instancias, `__dict__` | 50 000 | **0.90x (más chico)** | **1.05x más rápido** | 0.97x (empate) |

¹ el control confirma que la ganancia de tamaño de la fila de arriba (20% más
chico) viene de verdad de deduplicar por identidad, no de una casualidad del
tamaño de los dicts: sin objetos compartidos, la ventaja de tamaño de compickle
se reduce a un margen mucho menor (5% en vez de 20%) — la diferencia entre ambas
filas es la señal real de que el dedup de identidad está aportando algo
medible, no solo el propio formato binario siendo más compacto en general.

**Lectura honesta de estos números:** no hay un ganador uniforme. compickle
produce bytes de salida iguales o más chicos en 9 de 11 casos (a veces
significativamente, hasta 22% menos). En velocidad de serialize el resultado es
mixto: gana claramente en varios casos (hasta 2.1x), pierde claramente en otros
(hasta 4x más lento en el caso de muchas referencias compartidas por identidad,
investigado a fondo — ver nota abajo). `dill` fue consistentemente el más lento en
serialize en las 11 cargas de trabajo (10x-200x más lento que los demás), lo cual
es coherente con su diseño (introspección más profunda para máxima compatibilidad,
no es un defecto sino una decisión de diseño distinta). `pickle` y `cloudpickle`
rindieron de forma casi idéntica entre sí en todos los casos, como es esperable
dado que cloudpickle está construido sobre el protocolo de pickle.

**Investigación del caso de 4x más lento (dedup de identidad, muchas referencias
compartidas):** este resultado se investigó activamente antes de aceptarlo como
cifra final, porque contradecía la expectativa (es justo el patrón que el dedup
de identidad debería favorecer). Se descartaron como causa: contaminación de
estado entre mediciones (probado con procesos frescos por cada tamaño), la
bandera de compilación `-march=native` (probado compilando sin ella, mismo
patrón), y contención del allocator de memoria (probado con
`MALLOC_ARENA_MAX=1`, mismo patrón). Con 20 repeticiones y medición de tiempo de
CPU, el resultado se estabiliza en un rango de 1.07x-1.39x más lento — bastante
más modesto que las mediciones iniciales de pocas repeticiones, y consistente con
un overhead real y menor del mecanismo de dedup en este patrón específico, no con
un problema algorítmico grave. Se reporta la cifra completa (4x en la tabla de 9
repeticiones) en vez de la más favorable (1.27x en la de 20) porque es la que usa
la misma metodología que el resto de la tabla — la nota queda acá para que quien
lea la tabla tenga el contexto completo, no solo el número.

**Alcance de este benchmark:** 11 cargas de trabajo, un solo proceso en un
entorno de un núcleo. No cubre: payloads de cientos de MB, arrays de NumPy,
multiproceso/multihilo, ni comparación de uso de memoria pico (solo tiempo y
bytes de salida). Los tamaños de algunas cargas de trabajo se ajustaron por
límites de tiempo/CPU del entorno de medición (documentado en el propio código
del benchmark) — no para favorecer a ningún motor.

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

Medido en una ronda de trabajo anterior, 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 de esa ronda anterior — ver más arriba el benchmark de 1.3.0
con 11 cargas de trabajo y 4 motores para una comparación más amplia y reciente.

> ⚠️ **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

**Total acumulado a través de todas las rondas de trabajo, incluida la de
1.3.0: doce bugs de corrección**, más el pendiente de `cls_cache` (ya resuelto,
ver más abajo). Cada uno se documenta por separado con la evidencia que lo
confirma — esta sección no resume "se arreglaron cosas", enumera qué exactamente
y cómo se verificó.

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.

**[Corregido en la ronda de 1.3.0, tras dejarse pendiente en la ronda anterior]:**
en el motor C, `py_dedup_reset()` limpiaba `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 siempre prometió limpiar "los cachés de
source/exec/clases". Es decir, tras llamar `compickle.dedup_reset()` con el
backend C activo, las clases ya resueltas seguían cacheadas en `cls_cache`
indefinidamente. Se había dejado una nota explícita en el punto exacto donde se
resolvería, en vez de corregirlo sin confirmación previa — la confirmación llegó
en esta ronda, junto con el bug #12 de abajo, que le dio más relevancia práctica
(ver ese bug: `cls_cache` ahora también guarda clases resueltas por
re-vinculación de módulo, no solo por `exec()`). Verificado: tras `dedup_reset()`,
deserializar la misma clase la vuelve a resolver correctamente (por módulo si
sigue siendo importable, por `exec()` si no), sin regresión.

**Encontrado en la ronda de 1.3.0 (identidad de clase tras roundtrip):**

12. **`loads()` SIEMPRE reconstruía clases de usuario vía `exec()` del código
    fuente capturado, incluso cuando la clase ya era importable en el proceso
    actual — rompiendo `isinstance()`, `type(a) is type(b)`, herencia, y
    cualquier registro de clases tras un roundtrip, de forma silenciosa (sin
    error, sin warning).** Reproducido con un caso mínimo antes de asumir que
    era un problema: una clase definida a nivel de módulo (`workloads.
    SimpleRecord`, con `__slots__`), roundtrip vía `compickle.dumps`/`loads`, y
    comparación `type(original) is type(resultado)` → `False`. Confirmado que
    `pickle`, `dill`, y `cloudpickle` los tres SÍ preservan esa identidad para el
    mismo caso (los tres dan `True`) — no es un estándar inventado para esta
    sesión, es el comportamiento esperado que las tres librerías de referencia ya
    cumplían y compickle no. Reproducido también en el benchmark real de 11
    cargas de trabajo (`many_instances_slots`/`many_instances_dict`, ambas con
    `roundtrip_mismatch` antes del fix). Afectaba ambos backends por igual (C y
    Python), con la misma causa raíz: `_write_class_header`/`write_class_header`
    nunca escribían el `__module__` de la clase, así que el lector no tenía
    ninguna forma de saber si la clase ya estaba disponible sin ejecutar nada.

    **Corregido** agregando tags nuevos (`0x25`/`0x26`, en vez de mutar
    `0x1C`/`0x1E`, deliberadamente — ver más abajo por qué) que además del
    nombre y la fuente de la clase (formato sin cambios) incluyen su
    `__module__`. Al leer, `loads()` intenta primero `sys.modules[modulo].
    nombre` (consultando módulos **ya cargados**, nunca importando nada nuevo
    como efecto secundario — eso habría sido un problema de seguridad distinto,
    no una mejora) y valida con `isinstance(candidato, type)` que lo encontrado
    sea de verdad una clase, no una variable que por casualidad comparte
    nombre; solo si ese lookup falla (módulo no cargado, atributo ausente, no es
    una clase) recae en el `exec()` de siempre — mismo comportamiento que
    1.2.x para ese caso, sin regresión.

    **Decisión de versionado, no solo de código:** se evaluó extender el
    formato existente de `0x1C`/`0x1E` en vez de crear tags nuevos, y se
    descartó explícitamente — insertar un campo nuevo en medio de un formato
    que lectores más viejos ya interpretan haría que un compickle < 1.3.0
    leyera streams nuevos con los bytes desalineados **silenciosamente**
    (dato corrupto sin ningún error, el peor caso posible para un formato
    binario). Con tags nuevos, un lector viejo que encuentra `0x25`/`0x26`
    falla con `ValueError: Tag desconocido: 0x26` — explícito, inmediato, sin
    ambigüedad. Verificado en ambas direcciones: un compickle 1.2.6 sin
    editar generando un stream con datos correctos que un 1.3.0 lee bien (sin
    la mejora de identidad, porque esos bytes no traen `__module__` — pero sin
    romperse); y un 1.3.0 generando un stream que un 1.2.6 sin editar rechaza
    limpio con el error de tag desconocido, en vez de corromperse en silencio.
    Verificado en ambos backends por separado y cruzados entre sí (Python
    escribe / C lee, y viceversa) — las cuatro combinaciones dan roundtrip e
    identidad de tipo correctos.

    **Límite que se mantiene, sin intentar resolverse en esta ronda:** clases
    definidas en el REPL o vía `exec()` sin módulo real asociado (`__module__`
    resuelve a `'builtins'` o vacío) siguen sin poder re-vincularse — no hay
    módulo real que consultar. Siguen reconstruyéndose vía `exec()` de la
    fuente capturada, exactamente igual que en 1.2.x; confirmado que esto es
    una limitación preexistente y no algo introducido por este fix, comparando
    el mismo caso contra una copia sin editar de 1.2.6 (falla igual en ambas:
    `ValueError: No se puede obtener fuente de: <nombre>`).

    **Efecto colateral corregido en el camino:** al implementar el lookup por
    módulo en C, el primer intento pasaba `NULL` como identidad de objeto a una
    función que internamente llama `PyObject_Hash()` — comportamiento
    indefinido en la C API de CPython para ese caso (a diferencia de otras
    funciones de la API que sí toleran `NULL`). Detectado antes de compilar,
    revisando el código escrito con el mismo nivel de escrutinio que el resto
    de esta ronda; corregido usando un objeto string Python vacío real como
    identidad en ese caso, en vez de `NULL`.

---

## ⚡ 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.

---

## ⚡ Rendimiento — dispatch de `dumps()`/`loads()` y capacidad de `dedup_init()`

**Distinto de la sección "Rendimiento tras los fixes de dedup y dispatch" de
arriba**, que documenta los bugs #9/#10 (dedup de estructuras compartidas,
resuelto en una ronda de trabajo anterior a esta). Esta sección es sobre dos
mejoras separadas, de una ronda de trabajo posterior: el *dispatch* de qué
backend usar dentro de `dumps()`/`loads()` (no el dedup de contenido), y la
*capacidad inicial* de las tablas de hash de deduplicación en C (no si dedupear
o no). Nombres parecidos, trabajo distinto — se aclara aquí explícitamente para
que no se lean como la misma cosa.

**(a) Dispatch de backend.** Antes: `dumps()`/`loads()` resolvían qué backend
usar (C vs Python puro) llamando una función de carga en *cada* operación,
aunque esa resolución solo hace falta una vez por proceso. Ahora: se resuelve
una sola vez, en tiempo de `import compickle`, y `dumps()`/`loads()` quedan con
el mismo dispatch simple `if _USE_C: ... else: ...` que ya tenían, sin ninguna
llamada de función de más en el camino caliente.

Medido, tres corridas, objeto pequeño (`{"a": 1, "b": 2}`) en loop apretado,
backend C:

| | `dumps()` | `loads()` |
|---|---|---|
| Mejora | +6% a +8% | +12% a +14% |

Con objetos mixtos realistas (instancias de clase, no solo dicts pequeños), la
diferencia se diluye a ruido de medición (~0.5%) — el beneficio es
proporcionalmente mayor cuantas más operaciones pequeñas por segundo haga el
proceso, y marginal cuando el objeto en sí ya domina el tiempo de la operación.

**(b) Capacidad inicial de `dedup_init()` (backend C).** Antes: las tablas de
hash de deduplicación (`buckets`, `id_tab`) siempre arrancaban en capacidad fija
(512 entradas), sin importar el tamaño del objeto a serializar — un objeto de 2
claves pagaba el mismo costo de `malloc`+`memset`+`calloc` que uno de 200 000.
Ahora: la capacidad inicial es proporcional a una estimación del tamaño del
objeto raíz (misma heurística que ya existía para el buffer de salida).

Medido en tres tamaños, para cubrir tanto el caso que motivó el fix como el
riesgo del lado opuesto (capacidad inicial corta forzando varios *rehash* en un
objeto grande):

| Tamaño del objeto | Mejora en `dumps()` |
|---|---|
| Pequeño (2 claves) | **+20% a +21%** |
| Mediano (500 claves) | +4% a +5% |
| Grande (5000 entradas, con dedup real de identidad y contenido) | +2%, sin regresión |

Confirmado explícitamente que los bytes producidos son **idénticos** entre la
versión anterior y esta, incluso en el caso grande — una mejora de velocidad
que alterara el formato de salida habría sido inaceptable sin importar cuánto
mejorara el tiempo.

**Efecto combinado (a)+(b), objeto pequeño:** `dumps()` +24%, `loads()` +12%.

**Tests de rendimiento** (`tests/test_compickle.py`, clase `Rendimiento`, 4
casos): no verifican tiempos absolutos en milisegundos —un umbral así sería
frágil ante hardware de CI más lento que el de desarrollo, fallando sin que
haya ninguna regresión real— sino throughput mínimo con margen amplio (muy por
debajo de lo medido aquí) y corrección de la heurística de `dedup_init()` a
través de 13 tamaños de objeto, incluyendo los puntos borde justo antes y
después de donde la capacidad estimada cruza los topes originales.

---

## ⚡ Rendimiento — 1.3.2, comparativa completa contra `dill`, `cloudpickle`, `pickle`

Antes de esta ronda de trabajo se había medido rendimiento de forma aislada
(`compickle` contra sí mismo, antes/después de un cambio puntual), pero nunca
una comparativa amplia y actualizada contra los tres paquetes de referencia
sobre varios patrones de carga distintos a la vez. Esta sección cubre eso, con
metodología corregida respecto a benchmarks anteriores de este documento: cada
número es la **mediana de 7 corridas en el mismo proceso** (no el mínimo de
pocas corridas) — se adoptó la mediana tras confirmar, en el desarrollo de esta
misma ronda, que el mínimo de pocas muestras puede llevar a una lectura
incorrecta de dónde está un costo real.

**Seis escenarios**, elegidos para ejercitar patrones de uso distintos entre sí
(no solo "un objeto grande" o "muchos objetos chicos"): muchas instancias
compartiendo un perfil por identidad; muchas instancias sin nada compartido;
strings largas todas distintas; un único objeto con anidamiento profundo;
números en rangos variados; y una mezcla de los 6 tipos de soporte extendido.

| Escenario | `dumps()`: compickle vs mejor competidor | `loads()`: quién gana | Tamaño: quién gana |
|---|---|---|---|
| Muchas instancias, perfil compartido | 1.7x más lento (cloudpickle) | **compickle** | **compickle** (15% más chico) |
| Instancias sin nada compartido | 1.4x más lento (pickle) | **compickle** | pickle (algo más chico) |
| Strings largas, todas distintas | 1.8x más lento (pickle) | **compickle** | pickle (algo más chico) |
| Anidamiento profundo | 1.2x más lento (dill) | **compickle** | **compickle** (3.3x más chico) |
| Números en rangos variados | 1.6x más lento (pickle) | **compickle** | pickle (algo más chico) |
| Tipos de soporte extendido mixtos | **compickle gana, 5.5x más rápido que cloudpickle** | **compickle** | pickle no puede (falla) |

**Patrón honesto, sin maquillar:** `compickle` pierde en `dumps()` contra
`pickle`/`cloudpickle` en la mayoría de los escenarios donde no hay estructura
compartida por identidad — la causa es estructural, no un desperdicio
corregible con una optimización puntual (ver el diagnóstico completo más
abajo). En cambio, `compickle` **gana en `loads()` de forma consistente en los
seis escenarios**, y gana en tamaño de salida siempre que hay algo que
compartir por identidad (el margen puede ser grande: 3.3x más chico en el caso
de anidamiento profundo, porque cada nivel de anidamiento comparte estructura
con sus hermanos). Contra `dill` específicamente, `compickle` gana en `dumps()`
en 5 de 6 escenarios, a veces por un margen enorme (18x-25x) — `dill` prioriza
cobertura de tipos exóticos sobre velocidad de escritura, un trade-off de
diseño distinto, no un defecto suyo.

### Diagnóstico: por qué `dumps()` es más lento sin estructura compartida

Investigado con profiling real (`cProfile`, confirmando primero que casi todo
el tiempo de `dumps()` vive dentro de la extensión C — el wrapper de Python
alrededor no es el problema) y lectura del código C de bajo nivel. Causa
confirmada: **toda string ASCII, sin importar su longitud o si va a repetirse,
pasa por el mecanismo completo de deduplicación por contenido** — `PyObject_Hash`,
búsqueda en una tabla de hash propia (no la de CPython), y una copia de los
bytes a una arena de memoria dedicada. Ese trabajo es exactamente lo que
permite a `compickle` ganar tanto cuando el contenido SÍ se repite (ver la
columna de tamaño arriba), pero es costo estructural cuando no se repite —
`pickle` no mantiene una estructura de dedup por contenido tan elaborada, así
que en el caso de datos únicos tiene menos trabajo que hacer por diseño.

**Una optimización puntual para este caso se implementó, se midió, y se
revirtió dentro de esta misma ronda de trabajo** — se documenta aquí porque el
proceso en sí es información útil: un fast-path que evitaba el dedup por
contenido para strings ASCII muy cortas (≤8 bytes) cuando no coincidían por
identidad se probó en el backend C. Medido: **+4.9%** en el escenario que
motivó la idea, sin pérdida de tamaño en ese caso concreto. Pero se encontró un
caso real donde sí pierde compresión (contenido corto repetido como objetos
Python *distintos* — ej. el mismo valor leído repetidamente de una fuente
externa como filas de un CSV, donde cada lectura construye un objeto string
nuevo) y, más importante, **rompía la paridad de bytes entre el backend C y el
backend Python puro** que este proyecto mantiene deliberadamente en todo el
resto del código (ver las secciones de rendimiento anteriores, donde esa
paridad se verifica explícitamente en cada cambio). El backend Python puro usa
un `dict` nativo de CPython para el dedup, no una tabla de hash manual — el
mismo fast-path ahí no habría aportado la misma ganancia relativa, así que
replicarlo para restaurar la paridad no se justificaba. Se revirtió, y se deja
documentado como una vía explorada con evidencia, no una que simplemente no se
intentó.

`test_dumps_loads_throughput_minimo_objeto_pequeno` (`tests/test_compickle.py`,
sección `Rendimiento`) sigue protegiendo contra una regresión de arquitectura
en el wrapper de dispatch — este diagnóstico no encontró ninguna en esa capa,
solo confirmó que el costo real está en el motor de dedup por contenido del
backend C, que es donde vive el trade-off de diseño real de este formato.

---

## 🔐 Seguridad — 1.3.5

**Hallazgo real, no buscado a propósito para cumplir un checklist:** una
auditoría de seguridad centrada primero en el mecanismo de `exec()`/`marshal`
(el punto de mayor riesgo obvio en un formato que reconstruye clases desde
código fuente) confirmó que las protecciones existentes (`allow_exec=False`,
la advertencia documentada sobre `marshal.loads()` a nivel de parser C) ya
cubrían correctamente los casos revisados — incluyendo un camino de
resolución de callables por `sys.modules` que parecía sospechoso a primera
lectura pero que, verificado empíricamente, ya cae dentro del tag `0x27`
(`_EXEC_TAGS`), así que `allow_exec=False` lo rechaza antes de llegar ahí.

El hallazgo real apareció en otra dirección: una "bomba de profundidad" — un
stream de ~100KB que declara decenas de miles de niveles de anidamiento —
causaba **segmentation fault real** en el backend C, matando el proceso
Python entero sin ninguna excepción capturable. Confirmado en ambas
direcciones (`dumps()` con un objeto Python real profundamente anidado, y
`loads()` con un stream construido a mano, sin necesidad de que el objeto
real exista) y en ambos backends: el backend Python puro **nunca** tuvo este
problema (`RecursionError`, controlado — CPython protege su propio stack de
llamadas Python-a-Python), mientras que `serialize()`/`deserialize()` en C no
tenían ningún límite propio, a diferencia de `verify_walk()`, que sí lo tenía
desde antes (`depth > 200`).

**Corregido** envolviendo `serialize()`/`deserialize()` con wrappers delgados
que cuentan profundidad (variable global, segura sin lock porque el código
nunca libera el GIL — verificado explícitamente antes de asumirlo) y rechazan
con el mismo límite de `verify_walk()`, en vez de agregar un parámetro
`depth` a las ~33 llamadas recursivas internas de ambas funciones. Verificado
con evidencia: casos legítimos de anidamiento (150, 199 niveles) siguen
funcionando; el contador no queda atascado tras rechazos repetidos; y el
ataque original ahora falla con un `ValueError` claro en vez de crashear el
proceso.

**4 tests nuevos** (`tests/test_compickle.py`, sección `Seguridad`) — corridos
en **subproceso aislado**, deliberadamente: si el bug se reintrodujera, un
segfault dentro del propio proceso de test mataría el test runner completo
sin reportar nada útil; en subproceso se convierte en un `returncode`
negativo verificable con `assertEqual`, que sí puede fallar de forma
informativa.

## 🏷️ Etiquetado interno — 1.3.5

El límite de profundidad de anidamiento (200) vivía como **tres números
mágicos separados** antes de esta ronda: `verify_walk()` en C ya lo tenía
como literal sin nombre desde antes de que `serialize()`/`deserialize()`
tuvieran cualquier límite; al agregarles su propio límite para el fix de
seguridad de arriba, se definieron dos macros más (`MAX_SERIALIZE_DEPTH`,
`MAX_DESERIALIZE_DEPTH`) con el mismo valor `200` pero nombres distintos —
tres fuentes de verdad para un solo límite conceptual. Unificado bajo
`MAX_NESTING_DEPTH`, en paridad entre `compickle.c` y `compickle.py` (que
tenía el mismo literal `200` sin nombre en `_verify_walk`), junto al mapa de
tags que ya centraliza el resto de constantes del formato desde `1.3.1`.

## ⚡ Rendimiento — 1.3.5, caché de compresión de fuente persistente

**Backend Python puro.** Perfilado con `cProfile` (metodología de muchas
repeticiones dentro del mismo profile, no una sola medición — la lección de
la sección de rendimiento de `1.3.2` de este mismo documento, aplicada de
nuevo aquí) sobre un patrón de uso real: **muchas llamadas independientes** a
`dumps()` de instancias de la misma clase (ej. serializar uno por uno hacia
una cola o un socket, en vez de agrupar en una lista y una sola llamada).
Encontrado: `zlib.compress()` consumía **~39% del tiempo total** de `dumps()`
en este patrón, porque cada llamada independiente reseteaba `_dedup_cnt`
(correctamente — sus índices solo son válidos dentro de un stream
específico) y por lo tanto recomprimía la fuente de la clase desde cero cada
vez, aunque el contenido de esa fuente nunca cambia entre instancias.

Confirmado con un experimento de control antes de proponer el fix: agrupar
las mismas instancias en una única llamada a `dumps()` (donde `_dedup_cnt` sí
compartía la compresión entre todas ellas, como ya funcionaba antes de esta
ronda) hacía desaparecer `zlib.compress` casi por completo del perfil — el
problema era específico del patrón de llamadas independientes, no del
mecanismo de compresión en sí.

**El backend C ya tenía esta optimización desde antes** (`class_header_cache`,
con el mismo razonamiento) — el hallazgo era una asimetría entre backends,
no un problema nuevo de diseño. Corregido agregando `_compressed_source_cache`
al backend Python: cachea el resultado de comprimir por **contenido** de
fuente (no por identidad de clase — dos clases distintas con fuente idéntica
comparten entrada, igual que ya hacía `_dedup_cnt` dentro de un stream), de
forma **persistente entre llamadas** a `dumps()` — a diferencia de
`_dedup_cnt`, el resultado de comprimir no depende de qué stream se está
escribiendo, así que no hay razón para descartarlo entre llamadas. Limpiado
por `dedup_reset()` junto con los otros tres cachés de clase/función.

**Medido: +82.8%** en el patrón de uso que motivó el fix (20 000 instancias
de la misma clase, cada una en su propia llamada a `dumps()`, backend Python
puro). Verificado explícitamente que los bytes de salida son **idénticos**
con y sin el caché, en llamadas separadas — el caché solo evita repetir un
cálculo, nunca cambia qué se escribe. **2 tests nuevos** (sección
`Rendimiento`): corrección del roundtrip en llamadas independientes y paridad
de bytes con el backend Python forzado, y que `dedup_reset()` deja el caché
en un estado funcional tras limpiarlo — no un benchmark de tiempos dentro de
la suite, siguiendo el mismo criterio que el resto de tests de esta sección
(un umbral de tiempo en una suite de tests es frágil ante hardware de CI
variable; la mejora ya quedó documentada aquí con números reales).

## 🐛 Corrección de errores — 1.3.5

Tres bugs reales, introducidos por el propio trabajo de esta ronda y
encontrados **antes** de que llegaran a un tarball entregado — se documentan
aquí porque el proceso de encontrarlos es información útil, no para
esconderlos:

- Un typo real (caracteres cirílicos por error de tecleo en la palabra
  "literal", dentro de un comentario) — encontrado releyendo el propio texto
  antes de compilar, corregido de inmediato.
- El wrapper de profundidad de `_deserialize()` (ver la sección de seguridad
  arriba) usa decremento incondicional post-llamada en vez de `try`/`finally`
  por costo — medido: `try`/`finally` en ese hot path específico cuesta
  ~77% más en el peor caso aislado, contra +0.3% insignificante del mismo
  patrón en `_serialize()` (donde el trabajo real por llamada ya domina el
  costo del wrapper). Ese diseño de menor costo tiene una consecuencia real
  que el diseño inicial no consideró: cualquier excepción interna (no solo
  el propio rechazo por profundidad) deja el contador de cada nivel de la
  pila activo en ese momento sin decrementar. Sin un reset explícito entre
  llamadas de nivel superior, el contador se acumula con el uso normal
  (cualquier suite de tests que construya streams inválidos a propósito, por
  ejemplo) hasta superar el límite y rechazar incluso streams triviales sin
  ningún anidamiento real. Encontrado corriendo la suite completa en
  fallback puro-Python (29 de 53 tests fallaban) antes de dar el fix por
  terminado — corregido agregando el reset explícito a
  `_reset_read_state()`, verificado con la suite completa pasando de forma
  estable en dos corridas consecutivas del mismo proceso.
- Un test de seguridad (`test_profundidad_excesiva_no_causa_segfault_al_serializar`)
  asumía que el backend Python puro daría el mismo `ValueError` explícito que
  el backend C ante el ataque de 50 000 niveles. Verificado como falso: en
  la dirección de *escritura*, el stack real de Python (`sys.getrecursionlimit()`,
  ~1000 por defecto) se agota antes de que el contador lógico de
  `MAX_NESTING_DEPTH=200` "llamadas a `_serialize()`" llegue a acumularse —
  cada nivel de anidamiento real consume varios frames de Python entre una
  llamada y la siguiente, no uno solo. El resultado final sigue siendo
  seguro (`RecursionError`, capturable, sin crash), solo que no es la
  excepción específica que el test esperaba. Corregido el test para aceptar
  ambas excepciones como éxito — la propiedad real que protege es ausencia
  de crash, no el tipo exacto de excepción. (La dirección de *lectura*, por
  contraste, sí da el `ValueError` explícito incluso con 50 000 niveles: el
  bucle de lectura de listas es más plano, con menos capas de función
  intermedia por nivel — asimetría real entre lectura y escritura,
  confirmada, no forzada a ser igual.)

**53/53 tests pasan**, verificado en backend C y en fallback puro-Python, con
corridas repetidas para confirmar estabilidad (no solo una pasada).

---



`loads()` no es un lector de datos puro: **once** de sus opcodes (`0x0D`, `0x1C`,
`0x1E`, `0x1F`, `0x20`, `0x21`, `0x22`, `0x23`, `0x24`, `0x25`, `0x26` — función vía
fuente, instancia con `__dict__`/`__slots__` en su forma legacy y en su forma
1.3.0 con módulo, 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) *pueden* ejecutar 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()`.

**Matiz importante para `0x25`/`0x26` (nuevos en 1.3.0), a diferencia de los
otros nueve:** estos dos tags no *siempre* ejecutan código — primero intentan
re-vincular a una clase ya importada (`sys.modules`, sin importar nada nuevo), y
solo si eso falla recaen en `exec()` de la fuente capturada. `verify_stream()`
los reporta igual de conservador que a los demás (como "requieren posible
ejecución"), porque en tiempo de verificación estática no hay forma de saber si
el módulo estará cargado en el momento real de `loads()` — decir "esto no
ejecutará nada" de antemano sería una promesa que el propio mecanismo no puede
garantizar. `reporte.unexecuted_blocks` para estos tags explica exactamente esta
condición en el campo `motivo`.

`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 once
opcodes que pueden requerir 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
```

### `loads(datos, verify=True, allow_exec=False)` — usar `verify_stream()` al reconstruir, no solo antes

**Nuevo en 1.3.0.** Hasta acá, `verify_stream()` era una herramienta separada:
había que llamarla vos mismo antes de decidir si llamar `loads()`. Ahora
`loads()` puede usarla internamente, con dos parámetros independientes:

```python
# verify=True: si el stream está corrupto/mal formado, falla ANTES de
# empezar a deserializar, con un mensaje claro -- en vez de que la
# corrupción se descubra a mitad de reconstruir el objeto.
obj = compickle.loads(datos_no_confiables, verify=True)

# allow_exec=False: rechaza el stream si contiene CUALQUIER bloque que
# requeriría ejecutar código -- clases/instancias vía exec, funciones vía
# marshal, __reduce__, los envoltorios de método. No se ejecuta nada, ni
# siquiera el bloque que se rechaza: la decisión se toma ANTES de invocar
# el deserializador real, a partir del resultado de verify_stream() sobre
# el mismo stream. Implica verify=True automáticamente.
try:
    obj = compickle.loads(datos_no_confiables, allow_exec=False)
except compickle.UnsafeStreamError as e:
    print(f"Rechazado: {len(e.report.unexecuted_blocks)} bloque(s) requerían ejecución")
    for pos, tag, motivo, _ in e.report.unexecuted_blocks:
        print(f"  offset {pos}: {motivo}")
```

**Verificado con un espía, no solo argumentado:** una función cuyo cuerpo deja
evidencia observable si se ejecuta (`append` a una lista externa) se serializó
y se intentó deserializar con `allow_exec=False` — la excepción se levantó
correctamente y la lista quedó vacía, confirmando que el código nunca corrió.
Mismo resultado en ambos backends (C y Python).

**Lo que estos parámetros NO garantizan — para no leer de más:**

- `allow_exec=False` dice *"este stream no necesitó ejecutar nada para
  reconstruirse"* — no dice *"este stream es seguro"*. Un stream que pasa con
  `allow_exec=False` puede seguir conteniendo, por ejemplo, un `dict` con
  claves o valores diseñados para consumir memoria de forma abusiva; eso no es
  "ejecución de código" en el sentido que este parámetro cubre.
- **Límite verificado, no solo teórico:** `verify=True` reduce el riesgo de un
  fallo poco claro a mitad de la deserialización, pero no lo elimina para todo
  tipo de corrupción. Se confirmó un caso concreto: un stream con un solo byte
  corrompido dentro de un bloque de string de longitud declarada 1 pasa
  `verify_stream()` con `ok=True` (la estructura es consistente — longitud
  correcta, referencias en rango) y aun así falla en la deserialización real
  con `UnicodeDecodeError`, porque `verify_stream()` confirma consistencia
  *estructural*, no que el contenido decodifique correctamente. Esa excepción
  se deja propagar tal cual con `verify=True` (no se oculta ni se traduce a un
  mensaje genérico), porque es información más precisa sobre la causa real.
- Ambos parámetros tienen costo: `verify=True` camina el stream dos veces (una
  para verificar, otra para deserializar) en vez de una. Para streams grandes o
  de alta frecuencia donde ya se confía en el origen de los datos, el valor por
  defecto de ambos parámetros (sin verificación extra) evita ese costo doble —
  la verificación es opt-in, no automática, deliberadamente.

### `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).

**Dos hallazgos de portabilidad de esta sesión, verificados con un intento real de
instalación que falló antes de corregirse:**

- **`pyproject.toml` usa el formato de licencia SPDX (`license = "MIT"`), que
  requiere `setuptools >= 77`.** Con una versión de `setuptools` más vieja
  instalada localmente (68.1.2, en el entorno donde se probó todo lo de esta
  sesión), `pip install .` falla en la etapa de metadata, sin siquiera llegar a
  compilar el código C — con un mensaje de error de setuptools, no de compickle,
  lo que puede confundir sobre dónde está el problema real. No se corrigió en el
  código fuente (cambiar el formato de licencia es una decisión de quién mantiene
  el proyecto, no algo para decidir en el camino de arreglar otra cosa) — se deja
  documentado acá para que quien vea este error sepa que es de `setuptools`, no
  de `compickle` en sí. Si tu entorno tiene `setuptools` viejo y no podés
  actualizarlo, instalar con `pip install --no-build-isolation .` usando un
  `setuptools` más nuevo instalado aparte suele evitar el problema.
- **`setup.py` aplica `-march=native`/`-mtune=native` de forma casi
  incondicional**, pese a que el código sugiere que es una detección
  condicional (`if platform.machine():`) — esa condición es virtualmente
  siempre verdadera en cualquier sistema real (`platform.machine()` devuelve
  algo no vacío salvo en casos extremadamente raros), así que en la práctica
  esas dos flags se aplican siempre que se compila desde este `setup.py` tal
  cual. Esto significa que un `.whl` compilado en una máquina puede fallar con
  `SIGILL` (instrucción ilegal) si se ejecuta en otra máquina con un conjunto de
  instrucciones de CPU distinto — un riesgo real para quien distribuye binarios
  precompilados a máquinas heterogéneas (por ejemplo, CI que compila en un tipo
  de instancia y despliega en otro). No se corrigió en el código fuente por la
  misma razón que el punto anterior — decisión de quién mantiene el proyecto,
  no algo para cambiar sin que se pida explícitamente. Si vas a distribuir
  binarios precompilados a máquinas que no controlás, considerá compilar sin
  esas dos flags, o con `-march=x86-64`/equivalente en vez de `native`.

---

## 🚀 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

**[Simplificación en 1.3.0]:** la API pública es y siempre fue la que se
documenta acá — pero antes de esta versión, `compickle/compickle.py` (el módulo
interno, no el paquete) tenía su **propia copia completa** de `dumps`, `dump`,
`loads`, `load`, `backend`, `dedup_reset`, y `verify_stream`, con su propia
lógica de selección de backend, totalmente independiente y nunca usada por el
flujo real del paquete (`import compickle` siempre pasa por `compickle/
__init__.py`, nunca por `compickle.compickle` directamente). Esa segunda
superficie de API — alcanzable solo con un import explícito al módulo interno
que nada del propio proyecto hacía — se eliminó (excepto `verify_stream`, que sí
se sigue usando internamente como fallback Python real, y por eso se conservó).
Si tenías código que importaba desde `compickle.compickle` directamente en vez
de `compickle`, ese código necesita actualizarse — pero si usabas `import
compickle` normal, como recomienda toda esta documentación, este cambio es
invisible para vos.

### `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, verify: bool = False, allow_exec: bool = True) → object`

Deserializa directamente desde un objeto `bytes`. **`verify` y `allow_exec` son
nuevos en 1.3.0** — ver la sección
[`loads(datos, verify=True, allow_exec=False)`](#loadsdatos-verifytrue-allow_execfalse-usar-verify_stream-al-reconstruir-no-solo-antes)
más arriba para el comportamiento completo, incluidos sus límites verificados.
Ambos son opt-in con default que preserva el comportamiento de versiones
anteriores — `loads(data)` sin más argumentos se comporta exactamente igual que
antes de 1.3.0.

```python
obj = compickle.loads(raw_bytes)                          # sin cambios
obj = compickle.loads(raw_bytes, verify=True)              # falla claro si el stream está corrupto
obj = compickle.loads(raw_bytes, allow_exec=False)          # rechaza si requeriría ejecutar código
```

### `compickle.dedup_reset()`

Limpia los cachés de `source`/`exec`/clases del backend Python, y `source_cache`/
`exec_cache`/`class_header_cache`/`cls_cache` del motor C. **[Corregido en 1.3.0]**
antes de esta versión, `cls_cache` quedaba fuera de la limpieza en el motor C —
ver el bug correspondiente 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`, extendido en `1.3.0`
para reportar sobre los tags `0x25`/`0x26` (ver más abajo).* 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.StreamReport`

El tipo que devuelve `verify_stream()` — ver campos arriba. **[Simplificado en
1.3.0]:** antes accesible solo vía un `__getattr__` perezoso del paquete; ahora
es un import directo (`from .compickle import StreamReport` al tope de
`__init__.py`), visible en `dir(compickle)` sin necesitar conocer el mecanismo
de carga perezosa para descubrirlo.

```python
isinstance(reporte, compickle.StreamReport)  # True
```

### `compickle.UnsafeStreamError`

**Nuevo en 1.3.0.** Subclase de `ValueError`, se levanta desde `loads(data,
allow_exec=False)` cuando el stream contiene al menos un bloque que requeriría
ejecutar código. Trae el `StreamReport` completo que motivó el rechazo en
`.report`, para inspeccionar `unexecuted_blocks` sin tener que volver a llamar
`verify_stream()` por separado.

```python
try:
    obj = compickle.loads(datos, allow_exec=False)
except compickle.UnsafeStreamError as e:
    print(e.report.unexecuted_blocks)
```

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

Devuelve el motor activo.

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

### `compickle.legacy_tags() → tuple[dict]`

**Nuevo en 1.3.1.** Devuelve qué tags de formato antiguo esta versión sigue
aceptando en lectura, y con qué tag actual fue reemplazado cada uno. El
escritor (`dumps()`) nunca emite un tag legacy — esto es solo informativo,
para confirmar programáticamente qué formatos siguen siendo legibles antes
de archivar datos a largo plazo.

```python
compickle.legacy_tags()
# → ({'legacy_tag': 8,  'current_tag': 20, 'type': 'list',  'since_version': '1.3.0'},
#    {'legacy_tag': 9,  'current_tag': 22, 'type': 'tuple', 'since_version': '1.3.0'},
#    ...)
```

### `compickle.stream_format_info(data: bytes) → dict`

**Nuevo en 1.3.1.** Inspecciona un stream ya serializado y reporta si usa
tags legacy, actuales, o una mezcla — sin deserializar ni ejecutar nada (usa
`verify_stream()` internamente). Útil para decidir si conviene volver a
serializar datos viejos con `dumps()` para beneficiarse de dedup más
agresivo (nunca es obligatorio: los streams legacy siguen siendo válidos
indefinidamente).

```python
info = compickle.stream_format_info(data)
info['uses_legacy_format']   # → True/False
info['legacy_tags_found']    # → tupla de tags legacy presentes, ej. (9, 11)
info['tag_counts']           # → dict completo {tag: cantidad}
```

---

## 🧩 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, o re-vinculación por módulo si es importable — nuevo en 1.3.0) | Nombre + `inspect.getsource` + `__module__` (este último solo en el formato 1.3.0, ver nota), emitido como parte del header de clase dentro de `0x25`/`0x26` (escritura) o `0x1C`/`0x1E` (solo lectura, streams pre-1.3.0) |
| Instancia con `__dict__` | `0x25` (escritura); `0x1C` solo lectura, streams pre-1.3.0 | ✅ (re-vinculación por módulo si es importable, si no fuente vía `exec()`) | Encabezado de clase + `__dict__` (este último con tag `0x0C`, sin dedup de identidad — ver nota) |
| Instancia con `__slots__` | `0x26` (escritura); `0x1E` solo lectura, streams pre-1.3.0 | ✅ (re-vinculación por módulo si es importable, si no fuente vía `exec()`) | Recorre el MRO completo |
| Objeto con `__reduce__` | `0x27` (escritura); `0x1F` solo lectura, streams pre-1.3.1 | ✅ | 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` |
| `Ellipsis` (`...`) | `0x28` | — | 1 byte, singleton |
| `NotImplemented` | `0x29` | — | 1 byte, singleton |
| `dict.keys()` / `odict_keys` | `0x2A` | ✅ (vía la lista interna, `0x14`) | Materializado como `list` al leer — una vista de dict no tiene constructor público fuera de un dict vivo, así que no se puede reconstruir como el tipo view original |
| `dict.values()` / `odict_values` | `0x2B` | ✅ (vía la lista interna, `0x14`) | Materializado como `list` al leer, mismo motivo que `dict.keys()` |
| `dict.items()` / `odict_items` | `0x2C` | ✅ (vía la lista interna, `0x14`, con cada par como tupla `0x16`) | Materializado como `list` de tuplas `(k, v)` al leer |
| `types.MappingProxyType` (ej. `cls.__dict__`) | `0x2D` | ✅ (vía el dict interno, `0x0C`) | Los descriptores automáticos `__dict__`/`__weakref__` que trae toda clase sin `__slots__` se omiten al escribir — sin este filtro, serializar el `__dict__` de cualquier clase normal fallaría |
| `array.array` | `0x2E` | ✅ (typecode, vía `0x05`/`0x15`) | `typecode` + bytes crudos vía `tobytes()`/`frombytes()`, sin iterar elemento por elemento |

> **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 + fuente + módulo desde 1.3.0) ocurre como parte del header
> emitido al principio de los bloques `0x25`/`0x26` (`0x1C`/`0x1E` en su forma
> legacy, sin módulo), no como un opcode de nivel superior independiente.

> **Sobre `dict.keys()`/`.values()`/`.items()` materializados como `list`:** una vista
> de dict (`dict_keys`, `dict_values`, `dict_items`, y sus equivalentes `odict_*` de
> `OrderedDict`) no tiene constructor público independiente — solo existe ligada a un
> dict real y vivo. Por eso `compickle` no intenta reconstruir el tipo view original al
> deserializar: el resultado de `loads()` sobre una vista serializada es siempre una
> `list` (o `list` de tuplas para `.items()`), equivalente por contenido pero no por
> tipo exacto al objeto original. Mismo criterio que ya se aplicaba a generadores y
> corutinas (ver la fila `Generador / corutina` arriba), que tampoco se pueden
> reconstruir como tales y se materializan igual.

**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 → `0x25`; si no, `__slots__` sin
`__dict__` → `0x26`. 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 — matizada en 1.3.0:** al deserializar
vía fuente pura (`0x0D` para funciones, o `0x25`/`0x26`/`0x1C`/`0x1E` cuando la
clase NO resultó importable), `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`). **Esto cambió para clases (no para funciones) con el fix de
identidad de 1.3.0:** cuando la clase serializada con `0x25`/`0x26` SÍ es
importable en el proceso que deserializa, `loads()` recupera el objeto `type`
original real (misma identidad, `is` da `True`) — ver bug #12. Funciones y
lambdas (`0x0D`/`0x20`) no tienen este mecanismo de re-vinculación en esta versión;
siguen reconstruyéndose siempre por fuente o bytecode, con identidad distinta a la
original. 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 posible ejecución
for pos, tag, motivo, _ in reporte2.unexecuted_blocks:
    print(f"offset {pos}: 0x{tag:02X} — {motivo}")
# → offset 10: 0x25 — instancia: clase se re-vincula por módulo si está
#   importada, si no se materializa vía exec() de su fuente
# (verificado ejecutando este ejemplo real, no simulado -- el tag es 0x25,
#  no 0x1C, porque dumps() en 1.3.0 escribe con el formato nuevo por
#  defecto; ver 🔄 Compatibilidad de formato entre versiones)

# 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 10: instancia: clase se re-vincula por módulo si está importada,
#   si no se materializa 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.
```

---

## 🔄 Compatibilidad de formato entre versiones

**Nuevo en 1.3.0:** los tags `0x1C` (instancia con `__dict__`) y `0x1E`
(instancia con `__slots__`) fueron reemplazados como tags de **escritura** por
`0x25` y `0x26` respectivamente — que agregan el `__module__` de la clase al
header, necesario para el fix de identidad de clase (ver bug #12 en
[🐛 Bugs corregidos](#bugs-corregidos)).

**Nuevo en 1.3.1:** tres pares adicionales, mismo principio exacto:

| Tag legacy (solo lectura) | Tag actual (escritura) | Qué agrega |
|---|---|---|
| `0x09` (tuple) | `0x16` | dedup de identidad |
| `0x0B` (frozenset) | `0x17` | dedup de identidad |
| `0x1F` (`__reduce__`) | `0x27` | `__module__` del callable, para resolución por módulo (mismo fix que `0x1C`→`0x25`/`0x1E`→`0x26`, aplicado a `__reduce__`) |

En los cuatro casos (`0x1C`/`0x1E`/`0x1F` de 1.3.0, más `0x09`/`0x0B` de
1.3.1), el criterio es el mismo, deliberadamente: cuando el formato necesita
un campo nuevo, se introduce un tag NUEVO, nunca se muta el formato de uno
existente. La alternativa (insertar un campo dentro del formato que un tag ya
tenía) haría que un lector más viejo interpretara los bytes nuevos de forma
silenciosamente incorrecta, sin ningún error. Con tags nuevos, la
incompatibilidad — cuando la hay — es explícita y detectable, nunca
corrupción silenciosa. Puedes confirmar programáticamente qué tags legacy
sigue aceptando tu versión con `compickle.legacy_tags()`, e inspeccionar si
un stream específico usa formato legacy con
`compickle.stream_format_info(data)` — ver [🔌 API](#-api) para ambas.

**Qué significa esto en la práctica, según la dirección (aplica igual a los
cuatro pares — se muestra `0x1C`/`0x25` como ejemplo, el resto es idéntico):**

| Escritor | Lector | Resultado |
|---|---|---|
| compickle < 1.3.0 | compickle < 1.3.0 | Sin cambios, funciona igual que siempre. |
| compickle < 1.3.0 | compickle ≥ 1.3.0 | **Funciona.** Los tags `0x1C`/`0x1E` (y, para streams ≥ 1.3.0 sin los fixes de 1.3.1, `0x09`/`0x0B`/`0x1F`) se siguen leyendo correctamente — datos íntegros, sin error. La única diferencia: no se benefician de la mejora correspondiente (identidad de clase, dedup de tupla/frozenset, o identidad de clase en `__reduce__`), porque esos bytes no incluyen el campo nuevo. Verificado explícitamente para los cuatro pares, no solo asumido por el diseño — incluyendo un stream legacy construido a mano byte por byte para `0x09`/`0x0B`. |
| compickle ≥ 1.3.1 | compickle < 1.3.1 | **Falla con un error claro**, no con datos corruptos: `ValueError: Tag desconocido: 0x16` (o `0x17`/`0x27`). Verificado explícitamente en ambos backends. Si necesitás que archivos generados con una versión reciente sean legibles por un compickle más viejo que 1.3.1, esa combinación no es compatible — actualizá el lector, o generá los archivos con una versión anterior mientras tanto. |
| compickle ≥ 1.3.1 | compickle ≥ 1.3.1 | Funciona con todas las mejoras: dedup de tupla/frozenset, identidad de clase en instancias y en `__reduce__`. |

**Caso distinto — `verify_stream()` y los 6 tags de soporte extendido
(`0x28`-`0x2E`, introducidos en 1.3.1, ver [🧩 Tipos soportados](#-tipos-soportados)):**
no son un par legacy/actual como los de arriba (no tienen tag predecesor), así
que no encajan en esa tabla — se documentan aparte:

| Escritor | Lector | Resultado |
|---|---|---|
| compickle 1.3.1 (`dumps`, cualquier backend) | compickle 1.3.1 (`loads` normal, sin `verify`) | **Funciona.** El lector real siempre supo leer estos 6 tags — el bug de 1.3.1 estaba únicamente en el walker de verificación, no en la deserialización. |
| compickle 1.3.1 (`dumps`) | compickle 1.3.1 (`loads(data, verify=True)` o `allow_exec=False`) | **Fallaba** con `ValueError: Tag desconocido: 0x28` sobre datos perfectamente válidos — el bug corregido en 1.3.2. |
| compickle 1.3.1 (`dumps`) | compickle ≥ 1.3.2 (`loads(data, verify=True)` o `allow_exec=False`) | **Funciona.** Un stream generado por 1.3.1 con estos tipos ahora pasa verificación correctamente en 1.3.2 sin necesidad de regenerarlo. |
| compickle ≥ 1.3.2 (cualquier función) | compickle ≥ 1.3.2 (cualquier función) | Funciona en todos los casos, incluyendo `verify`/`allow_exec`. |

**Interoperabilidad cruzada entre backends (C ↔ Python):**
verificada en las cuatro combinaciones — Python escribe / Python lee, Python
escribe / C lee, C escribe / Python lee, C escribe / C lee — para los tres
pares nuevos de 1.3.1, además de los de 1.3.0, y para el fix de
`verify_stream()` de 1.3.2. Las cuatro dan
roundtrip correcto, tamaño de stream byte-idéntico entre backends, e
identidad de tipo preservada cuando la clase/módulo es importable en el
proceso lector. Un detalle verificado explícitamente: la resolución por
módulo depende de que el módulo de la clase ya esté cargado (`sys.modules`)
en el proceso que hace `loads()` — si el proceso lector nunca importó ese
módulo, cae correctamente al fallback de reconstrucción por fuente, sin
error espurio; esto es simétrico entre ambos backends.

**Qué NO cambió:** el resto del formato binario (todos los demás opcodes,
tags de tipos primitivos, mecanismo de deduplicación por contenido e
identidad, `0x13`/`0x14` para dict/list) es exactamente el mismo que en
versiones anteriores — cada uno de estos cambios de formato es específico y
acotado al tipo que menciona, no una revisión general del protocolo.

---

## 📦 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          [SOLO LECTURA desde 1.3.0, streams pre-1.3.0] instancia
              __dict__ formato legacy: class_header(sin módulo) +
              serialize(__dict__ vía 0x0C) -- ver 0x25 para el formato
              de escritura actual
0x1D          class source sin comprimir (dentro de dedup)
0x1E          [SOLO LECTURA desde 1.3.0, streams pre-1.3.0] instancia
              __slots__ formato legacy: class_header(sin módulo) +
              serialize(dict de slots vía 0x0C) -- ver 0x26 para el
              formato de escritura actual
0x1F          [SOLO LECTURA desde 1.3.1, streams pre-1.3.1] instancia
              __reduce__: callable_ref + args + flags + [state] +
              [list_items] + [dict_items] -- ver 0x27 para el formato
              de escritura actual
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)
0x25          [NUEVO 1.3.0, tag de ESCRITURA actual] instancia __dict__:
              class_header(CON módulo, str5 al final) +
              serialize(__dict__ vía 0x0C) -- ver bug #12
0x26          [NUEVO 1.3.0, tag de ESCRITURA actual] instancia __slots__:
              class_header(CON módulo, str5 al final) +
              serialize(dict de slots vía 0x0C) -- ver bug #12
0x27          [NUEVO 1.3.1, tag de ESCRITURA actual] instancia __reduce__:
              mismo cuerpo que 0x1F -- ver ese opcode para el detalle,
              0x27 reemplaza a 0x1F como tag de escritura desde 1.3.1
0x28          [NUEVO 1.3.1] Ellipsis (`...`): singleton, sin datos adicionales
0x29          [NUEVO 1.3.1] NotImplemented: singleton, sin datos adicionales
0x2A          [NUEVO 1.3.1] dict.keys()/odict_keys: serialize(list) -- se
              materializa como list al leer, ver "Tipos soportados"
0x2B          [NUEVO 1.3.1] dict.values()/odict_values: serialize(list) --
              se materializa como list al leer
0x2C          [NUEVO 1.3.1] dict.items()/odict_items: serialize(list de
              tuplas) -- se materializa como list de tuplas al leer
0x2D          [NUEVO 1.3.1] types.MappingProxyType (ej. cls.__dict__):
              serialize(dict vía 0x0C) -- los descriptores automáticos
              __dict__/__weakref__ que trae toda clase sin __slots__ se
              omiten al escribir
0x2E          [NUEVO 1.3.1] array.array: typecode (str5, tag 0x05/0x15) +
              longitud + bytes crudos vía tobytes()/frombytes()
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 SOLO cuando
  no son importables.** [Cambiado en 1.3.0] Antes de esta versión, `loads()`
  SIEMPRE reconstruía clases vía `exec()` del código fuente capturado, incluso
  cuando la clase ya era importable en el proceso actual — lo que rompía
  `isinstance()`, `type(a) is type(b)`, herencia, y cualquier registro de
  clases tras un roundtrip, de forma silenciosa. Verificado con
  `pickle`/`dill`/`cloudpickle`: los tres preservan identidad de clase cuando
  es importable; compickle 1.2.x no lo hacía. Desde 1.3.0, `loads()` intenta
  primero re-vincular a la clase real vía `sys.modules` (sin importar nada
  nuevo — solo módulos ya cargados) y solo recae en `exec()` de la fuente
  capturada si la clase no es importable (definida en REPL, `exec()`, o un
  módulo no cargado en el proceso que deserializa). La limitación de
  `inspect.getsource()` (no funciona con clases sin archivo de respaldo) sigue
  existiendo, pero ahora solo aplica a ese caso, no al caso común de clases de
  módulo estándar. Funciones normales y lambdas siguen funcionando en el REPL
  vía `marshal`, sin cambios. Streams generados por compickle < 1.3.0 se
  siguen leyendo correctamente (tags `0x1C`/`0x1E` legacy, ver
  [🔄 Compatibilidad de formato entre versiones](#compatibilidad-de-formato-entre-versiones)),
  simplemente sin la mejora de identidad porque esos bytes no traen la
  información de módulo necesaria.
- **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.
- **[Corregido en 1.3.0] `dedup_reset()` ya limpia `cls_cache` en el motor C.**
  Antes de esta versión, `cls_cache` (clases ya resueltas, sea por `exec()` o
  por la nueva re-vinculación de módulo) quedaba fuera de `dedup_reset()` en
  el backend C, aunque el docstring del lado Python siempre prometió limpiar
  "cachés de source/exec/clases". Verificado tras el fix: llamar
  `dedup_reset()` y luego deserializar la misma clase vuelve a resolverla
  correctamente (por re-vinculación de módulo si sigue siendo importable, o
  por `exec()` si no), sin regresión.
- **`verify_stream()` confirma estructura, no contenido decodificable —
  verificado empíricamente, no solo documentado.** `report.ok == True`
  garantiza tags válidos, longitudes consistentes, y referencias en rango —
  pero NO garantiza que los bytes dentro de un bloque de longitud declarada
  formen, por ejemplo, UTF-8 válido para un string. Se confirmó un caso
  concreto: un stream con un solo byte corrompido dentro de un string de
  longitud 1 pasa `verify_stream()` con `ok=True` y `bytes_consumed ==
  bytes_total`, y aun así falla en `loads()` real con `UnicodeDecodeError`.
  `loads(data, verify=True)` reduce el riesgo de un fallo poco claro a mitad
  de la deserialización (atrapa corrupción estructural antes de empezar),
  pero no lo elimina para este tipo específico de corrupción de contenido —
  ver el docstring de `loads()` para el detalle completo de esta limitación.
- **[Corregido en 1.3.1] Dedup de identidad extendido a `tuple`/`frozenset`.**
  Antes de esta versión, `tuple`/`frozenset` compartidos por identidad se
  re-serializaban completos en cada aparición, igual que `dict`/`list` antes de
  su propia ronda de dedup. Verificado con medición real: 1000 referencias a la
  misma tupla de 21 elementos pesaban 4.4x más que la lista equivalente (9029
  vs 2036 bytes) — con el fix, ambas pesan lo mismo. Para `frozenset`
  específicamente, la ganancia no es solo de tamaño: cada aparición repetía
  `sorted(..., key=repr)` sobre el contenido incluso siendo el mismo objeto,
  medido en 2.838ms para 1000 apariciones — con el chequeo de identidad al
  principio, ese costo se paga una sola vez (0.020ms medido, ~140x). También se
  corrige una pérdida de identidad silenciosa: tres referencias al mismo
  `tuple`/`frozenset` deserializaban como tres objetos distintos
  (`obj[0] is obj[1]` daba `False`); ahora se preserva, igual que `dict`/`list`.
  `set` (mutable) queda deliberadamente **fuera** de este fix — ver el punto
  siguiente.
- **Dedup de identidad NO extendido a `set` (mutable), por diseño.** A
  diferencia de `tuple`/`frozenset` (inmutables), un `set` compartido por
  identidad y mutado en un punto entre el registro del dedup y la lectura de su
  contenido, dentro de la misma llamada a `dumps()` (por ejemplo, desde un
  `__reduce__` personalizado con efectos secundarios), tendría una semántica
  más delicada de razonar con seguridad. Sin cambios respecto a versiones
  anteriores: se serializa completo en cada aparición, sin preservar identidad
  compartida tras el roundtrip. Documentado como pendiente explícito, no bug.
- **[Corregido en 1.3.1] Objetos con `__reduce__` perdían identidad de clase
  tras el roundtrip — verificado, no solo sospechado.** El callable devuelto
  por `__reduce__()` (normalmente la propia clase) se reconstruía SIEMPRE vía
  `exec()` de la fuente capturada, en un namespace nuevo y anónimo, incluso
  cuando la clase ya estaba importada en el proceso — el mismo defecto que
  `write_class_header_v2` ya había corregido para instancias normales
  (`0x1C`→`0x25`, `0x1E`→`0x26`), pero nunca se había aplicado al camino de
  `__reduce__` porque usa una función de resolución de callables
  completamente separada. Confirmado antes del fix:
  `isinstance(resultado_tras_roundtrip, LaClase)` daba `False`, y
  `resultado.__class__.__module__` terminaba en `'builtins'` (el default de
  `exec()` sin namespace real). Un `staticmethod` accedido vía su clase (ej.
  `Config._make`) tenía además un segundo síntoma del mismo origen:
  `IndentationError` al reconstruirse, porque `inspect.getsource()` captura el
  método con su indentación de método de clase, no ejecutable aislado. Ambos
  se corrigen con el mismo fix: `loads()` intenta primero re-vincular el
  callable por `__qualname__`+`__module__` vía `sys.modules` (mismo principio
  que ya usan las instancias desde 1.3.0), y solo recae en `exec()` si el
  módulo no está cargado. Nuevo tag `0x27` (ver
  [🔄 Compatibilidad de formato entre versiones](#compatibilidad-de-formato-entre-versiones));
  `0x1F` se mantiene como formato de solo lectura.
- **[Corregido en 1.3.1] Reutilización de memoria podía causar colisiones
  falsas de identidad en dedup — bug preexistente desde la introducción del
  dedup de contenedores, no específico de esta versión.** `args`/`state`/
  `list_items`/`dict_items` de `__reduce__` son objetos efímeros: si el único
  objeto Python que los mantenía vivos (la tupla `reduced` que `__reduce__()`
  devolvió) se liberaba al terminar de procesar una instancia, y uno de esos
  componentes quedaba registrado en la tabla de dedup por identidad de
  puntero, era posible que el intérprete reutilizara esa misma dirección de
  memoria para un objeto **distinto** más adelante en la misma llamada a
  `dumps()` — produciendo una coincidencia falsa. Reproducido y confirmado
  incluso contra el código sin ninguno de los cambios de esta sesión, con
  dedup de `list` (preexistente, no con el dedup de `tuple` nuevo de esta
  versión): dos instancias con `__reduce__ = lambda self: (Clase,
  ([self.v],))` deserializaban **ambas** con el valor de la primera. Corregido
  reteniendo el objeto `reduced` completo (no cada componente por separado)
  durante toda la llamada a `dumps()`, en ambos backends.

---

## 📄 Licencia

MIT — úsalo como quieras.

---

## 📝 Changelog — 1.3.5

Resumen de lo que cambió respecto a `1.3.2`. Pedido explícito para esta
ronda: seguridad, etiquetado interno, rendimiento con técnicas nuevas
(distintas a las ya probadas y, en un caso, revertidas en `1.3.2`), y
corrección de errores.

**Seguridad:**
- `serialize()`/`deserialize()` (backend C) no tenían ningún límite de
  profundidad de anidamiento propio, a diferencia de `verify_walk()`. Un
  stream o objeto con decenas de miles de niveles de anidamiento causaba
  **segmentation fault real**, no una excepción capturable. Corregido con
  wrappers que aplican el mismo límite (`MAX_NESTING_DEPTH`, ver abajo) sin
  cambiar la firma de ninguna de las ~33 llamadas recursivas internas de
  ambas funciones. El backend Python puro nunca tuvo este problema
  (`RecursionError`, ya controlado). 4 tests nuevos, corridos en subproceso
  aislado para que un eventual segfault se detecte como `returncode`
  negativo en vez de matar el test runner.

**Etiquetado interno:**
- El límite de profundidad (200) vivía en tres lugares con nombres
  distintos (`MAX_SERIALIZE_DEPTH`, `MAX_DESERIALIZE_DEPTH`, un literal sin
  nombre en `verify_walk()`). Unificado bajo `MAX_NESTING_DEPTH`, en
  paridad entre C y Python.

**Rendimiento (backend Python puro):**
- `zlib.compress()` consumía ~39% del tiempo total de `dumps()` en el
  patrón de muchas llamadas independientes a instancias de la misma clase,
  porque la fuente de la clase se recomprimía en cada llamada (el caché de
  dedup existente se resetea correctamente por-stream, no por-clase). El
  backend C ya evitaba esto desde antes (`class_header_cache`) — la
  asimetría entre backends era el hallazgo real. Corregido con
  `_compressed_source_cache`, persistente entre llamadas. Medido: **+82.8%**
  en el escenario que lo motivó, con bytes de salida verificados como
  idénticos con y sin el caché.

**Corrección de errores:** tres bugs propios de esta ronda, encontrados y
corregidos antes de dar el trabajo por terminado — un typo de tecleo, un
contador de profundidad que se acumulaba entre llamadas por faltar un
reset explícito (encontrado corriendo la suite completa en fallback
puro-Python: 29 de 53 tests fallaban), y un test que asumía sin verificar
que ambos backends darían el mismo tipo de excepción ante un ataque
extremo. Ver la sección "🐛 Corrección de errores — 1.3.5" más arriba para
el detalle completo de cada uno.

**Sin cambios de comportamiento para código existente:** ningún tag emitido
por versiones anteriores cambió de significado; el fix de seguridad y el
caché de compresión son ambos invisibles desde el formato de bytes en el
caso exitoso — mismos bytes de salida que antes, solo con mejor
rendimiento y protección adicional ante entrada adversarial extrema.

**53/53 tests pasan**: 47 heredados de `1.3.2`, más 4 de la sección
`Seguridad` y 2 de la sección `Rendimiento` agregados en esta ronda,
verificado en backend C y fallback puro-Python.

---

## 📝 Changelog — 1.3.2

Resumen de lo que cambió respecto a `1.3.1`.

**Corrección de fondo:**
- `verify_stream()` (y por extensión `loads(data, verify=True)` y
  `loads(data, allow_exec=False)`) rechazaban con `"Tag desconocido: 0x28"`
  cualquier stream que contuviera alguno de los 6 tipos de soporte extendido
  agregados en `1.3.1` (`Ellipsis`, `NotImplemented`, `dict.keys()`/`.values()`/
  `.items()`, `types.MappingProxyType`, `array.array`) — aunque el stream fuera
  perfectamente válido y `loads(data)` sin verificación lo leyera bien. Causa:
  al agregar esos 6 tags se actualizaron `serialize()`/`deserialize()` en ambos
  backends, pero nunca el walker de verificación de estructura
  (`_verify_walk` en Python, `verify_walk` en C). Corregido en ambos backends,
  en paridad; test de regresión permanente agregado
  (`SoporteExtendido.test_verify_stream_reconoce_los_6_tipos_extendidos`).
- El bug de string truncada leída en silencio (backend Python puro, corregido
  en `1.3.1`) no tenía equivalente aquí — se re-verificó como parte de esta
  ronda que sigue corregido, sin encontrar regresión.

**Rendimiento (backend C):**
- El dispatch de `dumps()`/`loads()` resolvía qué backend usar (C vs Python
  puro) en cada llamada, vía una función con guard que, tras la primera
  invocación, no hacía más que comprobar una condición y volver — coste de
  llamada de función real, medido. Ahora se resuelve una sola vez, en tiempo
  de `import compickle`. Medido: `dumps()` +6% a +8%, `loads()` +12% a +14%
  en operaciones pequeñas repetidas; el efecto se diluye a ruido con objetos
  grandes, donde el trabajo real de serializar domina el tiempo.
- `dedup_init()` siempre reservaba tablas de hash de capacidad fija (512
  entradas) sin importar el tamaño del objeto a serializar. Ahora la
  capacidad inicial es proporcional a una estimación del objeto raíz.
  Medido: objetos pequeños +20% a +21%, medianos +4% a +5%, grandes (con
  dedup real de contenido e identidad) +2%, sin regresión en ningún tamaño
  probado. Confirmado que los bytes de salida no cambiaron.
- **Explorado y revertido, documentado con evidencia:** un fast-path que
  evitaba el dedup por contenido para strings ASCII muy cortas (≤8 bytes)
  cuando no coincidían por identidad se implementó, se midió (+4.9% en el
  escenario que lo motivó), y se revirtió — rompía la paridad de bytes entre
  el backend C y el backend Python puro que este proyecto mantiene
  deliberadamente, y tenía un caso real de pérdida de compresión (contenido
  corto repetido como objetos Python distintos) que la ganancia medida no
  justificaba. Ver la sección de rendimiento de 1.3.2 más arriba para el
  diagnóstico completo de por qué `dumps()` es más lento que `pickle` sin
  estructura compartida — es una propiedad estructural del mecanismo de
  dedup universal por contenido, no un desperdicio corregible sin riesgo.

**Benchmark nuevo:** comparativa completa contra `dill`/`cloudpickle`/`pickle`
sobre 6 escenarios de carga distintos (ver la sección de rendimiento de 1.3.2
más arriba) — la primera de este documento en cubrir varios patrones de uso a
la vez con los tres paquetes de referencia simultáneamente, no solo `compickle`
contra sí mismo.

**Sin cambios de comportamiento para código existente:** ningún tag emitido
por versiones anteriores cambió de significado; todo dato ya serializado
sigue siendo legible sin volver a generarlo. El fix de `verify_stream()` solo
hace que streams *previamente rechazados por error* ahora se acepten
correctamente — ningún stream que antes pasaba verificación deja de pasarla.

---

## 📝 Changelog — 1.3.1

Resumen de lo que cambió respecto a `1.3.0`.

**Corrección de fondo:**
- Dedup de identidad extendido a `tuple`/`frozenset` (tags nuevos `0x16`/
  `0x17`) — antes, ambos se re-serializaban completos en cada aparición
  incluso siendo el mismo objeto compartido. Medido: 4.4x más bytes para
  1000 referencias a la misma tupla de 21 elementos; para `frozenset`,
  además ~140x más lento por repetir `sorted(key=repr)` en cada aparición.
  `set` (mutable) queda deliberadamente fuera de este fix — ver
  [⚠️ Limitaciones conocidas](#-limitaciones-conocidas).
- Objetos con `__reduce__` personalizado ahora preservan identidad de clase
  tras el roundtrip cuando la clase es importable (antes, el callable de
  `__reduce__` siempre se reconstruía vía `exec()`, incluso para clases de
  módulo estándar — el mismo defecto que el bug #12 de 1.3.0 había corregido
  para instancias normales, pero nunca se había aplicado a `__reduce__`
  porque usa un camino de resolución de callables separado). Nuevo tag
  `0x27` con `__module__` incluido; `0x1F` se conserva solo como formato de
  lectura.
- Corregida una condición de reutilización de memoria que podía causar
  colisiones falsas de identidad en el dedup de contenedores dentro de
  `__reduce__` — bug preexistente desde la introducción del dedup de
  contenedores (no específico de esta versión), expuesto al ejercitar el
  camino con más profundidad durante el trabajo de esta ronda. Los
  componentes de `__reduce__` (`args`/`state`/`list_items`/`dict_items`) se
  retienen ahora explícitamente durante toda la llamada a `dumps()`.

**API pública — nuevo:**
- `compickle.legacy_tags()`: expone qué tags de formato antiguo el lector
  actual sigue aceptando, y con qué tag actual fue reemplazado cada uno.
- `compickle.stream_format_info(data)`: inspecciona un stream ya
  serializado y reporta si usa tags legacy, actuales, o una mezcla, sin
  deserializar ni ejecutar nada.

**Sin cambios de comportamiento para código existente:** ningún tag emitido
por versiones anteriores cambió de significado; todo dato ya serializado
sigue siendo legible sin volver a generarlo. Ver
[🔄 Compatibilidad de formato entre versiones](#-compatibilidad-de-formato-entre-versiones)
para el detalle completo, verificado en ambas direcciones y ambos backends.

---

## 📝 Changelog — 1.3.0

Resumen de lo que cambió respecto a `1.2.6`, la versión anterior real (no
`1.2.4`, que era lo que este documento decía antes por una desactualización sin
corregir — corregida en esta ronda).

**Corrección de fondo:**
- Bug #12: identidad de clase preservada tras roundtrip cuando la clase es
  importable (antes, `loads()` siempre reconstruía vía `exec()`, incluso para
  clases de módulo estándar — rompía `isinstance`/`type is`/herencia
  silenciosamente). Nuevo formato de tags `0x25`/`0x26` con `__module__`
  incluido; `0x1C`/`0x1E` se conservan solo como formato de lectura para
  streams generados por versiones anteriores.
- `dedup_reset()` ahora limpia `cls_cache` también en el motor C (antes
  quedaba fuera, documentado como pendiente en la ronda anterior).

**API pública — nuevo:**
- `loads(data, verify=True)`: verifica estructura antes de deserializar,
  falla claro si el stream está corrupto.
- `loads(data, allow_exec=False)`: rechaza streams que requerirían ejecutar
  código, sin ejecutar nada — verificado con un espía que confirma que el
  código nunca corre.
- `compickle.UnsafeStreamError`: la excepción que levanta `allow_exec=False`,
  con el `StreamReport` completo adjunto en `.report`.

**API pública — simplificación:**
- Eliminadas 6 funciones completas (`dumps`, `dump`, `loads`, `load`,
  `backend`, `dedup_reset`) que existían duplicadas en `compickle/compickle.py`
  (el módulo interno) sin que nada del paquete las usara jamás — una segunda
  superficie de API paralela, alcanzable solo con un import directo al módulo
  interno. `verify_stream` se conservó ahí porque sí se usa internamente como
  fallback real.
- `StreamReport` pasó de estar detrás de un `__getattr__` perezoso a ser un
  import directo — visible en `dir(compickle)` sin necesitar conocer el
  mecanismo de carga interna.
- Removida la exposición accidental de `_USE_C`/`serialize_fast`/
  `deserialize_fast` como pseudo-API vía `__getattr__` (nada externo las usaba).

**Documentación:**
- Benchmark real contra `pickle`, `cloudpickle` 3.1.2, y `dill` 0.4.1 en 11
  cargas de trabajo — reemplaza cifras de rondas anteriores que solo cubrían
  `pickle`.
- Sección de seguridad ampliada, incluyendo el matiz de que `0x25`/`0x26` no
  siempre ejecutan código (pueden resolverse por módulo) pero `verify_stream()`
  los reporta igual de conservador que a los que sí siempre ejecutan.
- Nueva sección [🔄 Compatibilidad de formato entre versiones](#compatibilidad-de-formato-entre-versiones)
  documentando las cuatro combinaciones de escritor/lector entre versiones.
- Corregida la versión reportada en el badge y en el propio `__version__` del
  paquete (decían `1.2.4`/`1.2.6` respectivamente; ninguna coincidía con la
  versión real del paquete al empezar esta ronda).
- Documentados dos hallazgos de portabilidad del build (`pyproject.toml`
  requiere `setuptools >= 77` por el formato SPDX de licencia; `-march=native`
  se aplica casi siempre, no condicionalmente) — no corregidos en el código,
  decisión de quien mantiene el proyecto, pero documentados para que no
  sorprendan a quien instale en un entorno distinto.

**Compatibilidad:** streams generados por `compickle < 1.3.0` se siguen
leyendo correctamente con `1.3.0` (sin la mejora de identidad de clase, que
requiere el campo de módulo nuevo). Streams generados por `1.3.0` fallan con
un error claro (`Tag desconocido`) si se leen con una versión anterior a
`1.3.0` — no se corrompen en silencio. `loads(data)` sin argumentos nuevos se
comporta exactamente igual que antes de esta versión.
