Metadata-Version: 2.4
Name: fastcorex
Version: 0.3.0
Summary: Extension en C para acelerar loops y estructuras de datos comunes
License: MIT
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: C
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: license
Dynamic: license-file
Dynamic: requires-python
Dynamic: summary

# fastcorex

Extensión en C para acelerar loops y estructuras de datos comunes en Python. No es un reemplazo de NumPy ni Pandas — es una capa delgada que elimina las partes tediosas y repetitivas (agrupar, deduplicar, aplanar, contar, filtrar, particionar, fusionar dicts, generar slugs, acceso anidado seguro, rellenar texto, ventanas deslizantes) que normalmente se reescriben a mano en cada proyecto.

## Instalación

```
pip install fastcorex
```

Requiere Python 3.9 o superior y un compilador de C (se compila al instalar, como cualquier extensión nativa).

## Uso rápido

```python
import fastcorex as fx

fx.fast_sum([1, 2, 3])                      # 6.0
fx.count_freq(["a", "b", "a"])              # {'a': 2, 'b': 1}
fx.unique([3, 1, 2, 1, 3])                  # [3, 1, 2]
fx.filter_gt([1, 5, 10, 3], 4.0)            # [5.0, 10.0]
fx.groupby(lista_de_dicts, "categoria")     # dict agrupado
fx.flatten([1, [2, [3, 4]], 5])             # [1, 2, 3, 4, 5]
fx.unique_by(lista_de_dicts, "id")          # FastList dedup por campo (encadenable)
fx.chunk([1, 2, 3, 4, 5], 2)                # [[1, 2], [3, 4], [5]]
fx.safe_get(d, "a.b.c", default=None)       # acceso anidado sin try/except
fx.clamp(15.0, 0.0, 10.0)                   # 10.0
fx.pick({"a": 1, "b": 2, "c": 3}, ["a"])    # {'a': 1}
fx.omit({"a": 1, "b": 2, "c": 3}, ["a"])    # {'b': 2, 'c': 3}
fx.deep_merge(config_base, config_local)    # dict fusionado recursivamente
fx.slugify("Título con Ñandú")              # "titulo-con-nandu"
fx.partition(numeros, lambda n: n > 0)      # (positivos, no_positivos)
fx.dedupe_consecutive([1, 1, 2, 2, 1])      # [1, 2, 1]
fx.flatten_dict({"a": {"b": 1}})            # {'a.b': 1}
fx.invert_dict({"a": 1, "b": 2})            # {1: 'a', 2: 'b'}
fx.pad("hi", 6, mode="center")              # "  hi  "
fx.clip_outliers([1, -50, 100], 0, 10)      # [1.0, 0.0, 10.0]
fx.rolling_window([1, 2, 3, 4], 2)          # [[1, 2], [2, 3], [3, 4]]
fx.filter_range(numeros, 5, 15)             # sin callback, más rápido que filter()
fx.partition_gt(numeros, 10)                # sin callback, más rápido que partition()
fx.ensure_list(5)                           # [5]
fx.first_or_default(lista, predicado)       # primer match o default
```

## Documentación de funciones

### Funciones de la versión 0.1.x

**fast_sum(lista)** — Suma todos los elementos numéricos de una lista.
```python
fx.fast_sum([1, 2, 3, 4.5])  # 10.5
```

**count_freq(lista)** — Cuenta cuántas veces aparece cada elemento. Reemplaza un loop de 4 líneas con `dict.get()` por una sola llamada.
```python
fx.count_freq(["a", "b", "a", "c", "b", "a"])  # {'a': 3, 'b': 2, 'c': 1}
```

**unique(lista)** — Elimina duplicados manteniendo el orden original. Reemplaza el patrón de `set()` + loop + `append`.
```python
fx.unique([3, 1, 2, 1, 3, 4])  # [3, 1, 2, 4]
```

**filter_gt(lista, umbral)** — Devuelve solo los elementos mayores al umbral dado.
```python
fx.filter_gt([1, 5, 10, 3, 8], 4.0)  # [5.0, 10.0, 8.0]
```

**groupby(lista_de_dicts, clave)** — Agrupa una lista de diccionarios según el valor de una clave. Devuelve un dict normal. Reemplaza el patrón de dict + `setdefault` manual.
```python
fx.groupby(ventas, "categoria")  # {'ropa': [...], 'comida': [...]}
```

**flatten(lista_anidada)** — Aplana listas anidadas de cualquier profundidad. Reemplaza una función recursiva escrita a mano.
```python
fx.flatten([1, [2, 3, [4, [5, 6]], 7], 8])  # [1, 2, 3, 4, 5, 6, 7, 8]
```

**unique_by(lista_de_dicts, clave)** — Deduplica diccionarios según el valor de un campo específico, no el objeto completo. Devuelve un `FastList` (ver sección de encadenamiento).
```python
fx.unique_by(ventas, "id")  # FastList sin ids repetidos
```

**chunk(lista, tamaño)** — Parte una lista en sublistas de tamaño fijo, sin solaparse; el último chunk puede quedar más corto. Reemplaza el slicing manual con `range(0, len(lista), tamaño)`.
```python
fx.chunk([1, 2, 3, 4, 5, 6, 7], 3)  # [[1, 2, 3], [4, 5, 6], [7]]
```

**safe_get(dict, "a.b.c", default=None)** — Acceso anidado seguro a diccionarios usando un path con puntos. Reemplaza el `try/except (KeyError, TypeError)` que normalmente envuelve un acceso encadenado. `default` puede pasarse posicional o como keyword.
```python
fx.safe_get({"a": {"b": {"c": 42}}}, "a.b.c")            # 42
fx.safe_get({"a": {"b": {}}}, "a.b.c", default="N/A")    # "N/A"
```
Un path vacío, o con un segmento vacío (`"a..b"`, `".a"`, `"a."`), lanza `ValueError` en vez de devolver silenciosamente el default — un path malformado suele ser un bug en quien llama, no un caso de "no encontrado".

**clamp(valor, minimo, maximo)** — Acota un número al rango `[minimo, maximo]`. Reemplaza `max(minimo, min(valor, maximo))` o un if/elif/else.
```python
fx.clamp(15.0, 0.0, 10.0)  # 10.0
fx.clamp(-5.0, 0.0, 10.0)  # 0.0
```

### Funciones de la versión 0.2.0

**pick(dict, claves)** — Devuelve un nuevo dict con solo las claves indicadas que existan en el original; las ausentes se ignoran sin error. Reemplaza `{k: d[k] for k in claves if k in d}`.
```python
fx.pick({"nombre": "Ana", "edad": 30, "email": "a@x.com"}, ["nombre", "email"])
# {'nombre': 'Ana', 'email': 'a@x.com'}
```

**omit(dict, claves)** — Lo inverso de `pick`: devuelve un nuevo dict sin las claves indicadas. Reemplaza `{k: v for k, v in d.items() if k not in claves}`.
```python
fx.omit({"nombre": "Ana", "password": "secreta", "edad": 30}, ["password"])
# {'nombre': 'Ana', 'edad': 30}
```

**deep_merge(base, override)** — Fusiona `override` sobre `base` recursivamente: cuando ambos tienen un dict en la misma clave, se fusionan sus contenidos en vez de que uno reemplace al otro; en cualquier otro caso, `override` gana. Ninguno de los dos argumentos originales se modifica.
```python
config_base = {"db": {"host": "localhost", "port": 5432}, "debug": False}
config_local = {"db": {"port": 5433}}
fx.deep_merge(config_base, config_local)
# {'db': {'host': 'localhost', 'port': 5433}, 'debug': False}
```

**slugify(texto)** — Normaliza un string a minúsculas, sin acentos (cubre á é í ó ú ü ñ ç y sus mayúsculas), con guiones en vez de espacios o símbolos, sin guiones duplicados ni al inicio/final.
```python
fx.slugify("Título de Sección: ¡Importante!")  # "titulo-de-seccion-importante"
```

**partition(lista, predicado)** — Recorre la lista una sola vez y la separa en `(cumplen, no_cumplen)` según `predicado(item)`, en vez de hacer dos pasadas. Devuelve una tupla de dos `FastList`. **Nota de rendimiento:** ver la sección de benchmarks — para el caso de "separar por un umbral numérico", `partition_gt` es 4x-6x más rápida.
```python
pares, impares = fx.partition(range(10), lambda n: n % 2 == 0)
# ([0, 2, 4, 6, 8], [1, 3, 5, 7, 9])
```

**dedupe_consecutive(lista)** — Colapsa elementos repetidos que aparecen uno justo después del otro. A diferencia de `unique()`, no deduplica globalmente: `dedupe_consecutive([1, 2, 1])` deja `[1, 2, 1]` intacto, porque el segundo `1` no es consecutivo con el primero.
```python
fx.dedupe_consecutive([1, 1, 2, 2, 2, 1, 3, 3])  # [1, 2, 1, 3]
```

### Funciones nuevas (0.3.0)

**flatten_dict(dict, sep=".")** — Aplana un dict anidado a un solo nivel, generando claves tipo `"a.b.c"` para cada valor no-dict encontrado en profundidad. Es el inverso conceptual de `safe_get`: en vez de bajar por un path con puntos, genera todos los paths posibles de una vez. Los valores que son listas **no** se aplanan, se conservan tal cual.
```python
fx.flatten_dict({"usuario": {"nombre": "Ana", "direccion": {"ciudad": "Lima"}}})
# {'usuario.nombre': 'Ana', 'usuario.direccion.ciudad': 'Lima'}
fx.flatten_dict({"a": {"b": 1}}, sep="/")  # {'a/b': 1}
```
Útil para "achatar" una respuesta de API anidada antes de escribirla a una fila de CSV o de base de datos plana.

**invert_dict(dict)** — Devuelve un nuevo dict con claves y valores intercambiados. Si hay valores duplicados, el último gana (mismo comportamiento que reconstruirlo a mano con un loop). Los valores deben ser hasheables, igual que exige Python al usarlos como clave.
```python
fx.invert_dict({"rojo": "#FF0000", "verde": "#00FF00"})
# {'#FF0000': 'rojo', '#00FF00': 'verde'}
```
Reemplaza: `{v: k for k, v in d.items()}`.

**pad(texto, ancho, fill=" ", mode="right")** — Rellena un string a un ancho mínimo. `mode` puede ser `"right"` (rellena a la derecha, texto alineado a la izquierda), `"left"` (rellena a la izquierda, texto alineado a la derecha) o `"center"`. Si el texto ya mide `ancho` o más, se devuelve sin cambios.
```python
fx.pad("hi", 6)                      # "hi    "
fx.pad("hi", 6, mode="left")         # "    hi"
fx.pad("hi", 6, mode="center")       # "  hi  "
fx.pad("hi", 6, fill="*")            # "hi****"
```
Es equivalente a `str.ljust`/`str.rjust`/`str.center`, pero unificados bajo un solo nombre de parámetro (`mode`) y con validación explícita del carácter de relleno (debe ser exactamente uno) y del valor de `mode`, en vez de tener que recordar cuál de los tres métodos usar y qué pasa si `fill` mide más de un carácter (con los métodos nativos, silenciosamente solo se usa mal). Ver la sección de benchmarks para las dos comparaciones de rendimiento distintas que aplican aquí.

**clip_outliers(lista, minimo, maximo)** — Devuelve una nueva lista con cada número acotado al rango `[minimo, maximo]`. Es `clamp()` aplicado a una lista completa de una vez, en vez de un loop + `clamp()` por elemento.
```python
fx.clip_outliers([1.0, -50.0, 100.0, 5.0], 0.0, 10.0)  # [1.0, 0.0, 10.0, 5.0]
```
Útil para descartar valores atípicos de sensores, precios o mediciones antes de graficarlos o promediarlos.

**rolling_window(lista, tamaño)** — Genera una lista de sublistas, cada una una "ventana" de `tamaño` elementos consecutivos que se desliza de a uno. A diferencia de `chunk()` (que particiona sin solapamiento), `rolling_window` sí solapa: con tamaño 2, `[1,2,3,4]` da `[[1,2],[2,3],[3,4]]`, no `[[1,2],[3,4]]`. Si `tamaño` es mayor que la longitud de la lista, devuelve una lista vacía.
```python
fx.rolling_window([1, 2, 3, 4, 5], 3)  # [[1, 2, 3], [2, 3, 4], [3, 4, 5]]
```
Pensado para promedios móviles, detección de tendencias, o comparar cada elemento contra su vecindario inmediato — ver el ejemplo de encadenamiento con `.map()` más abajo para un promedio móvil real.

### Especializadas de rendimiento (0.3.0)

Estas dos funciones resuelven el mismo problema que `partition()`/`.filter()` con una lambda de comparación numérica, pero **sin invocar ningún callback de Python** — ver la sección de benchmarks para el porqué y la magnitud real de la mejora (4x-7x según el caso).

**filter_range(lista, minimo, maximo, inclusive=True)** — Devuelve los elementos dentro de `[minimo, maximo]` (con `inclusive=True`, el valor por defecto) o `(minimo, maximo)` exclusivo en ambos extremos (`inclusive=False`). Generaliza `filter_gt` a un rango completo.
```python
fx.filter_range([1, 5, 10, 15, 20], 5, 15)                    # [5, 10, 15]
fx.filter_range([1, 5, 10, 15, 20], 5, 15, inclusive=False)   # [10]
```

**partition_gt(lista, umbral)** — Especialización de `partition()` para separar por un único umbral numérico: devuelve `(mayores, resto)`, igual que `partition(lista, lambda x: x > umbral)` pero sin el costo de invocar Python en cada elemento.
```python
fx.partition_gt([1, 5, 10, 15, 20], 10)  # ([15, 20], [1, 5, 10])
```

### Utilidades en Python puro (0.2.0)

Estas dos funciones se implementaron directamente en Python, no en C, porque su costo ya es mínimo en el intérprete y una extensión de C no traería ninguna ganancia medible.

**ensure_list(valor)** — Envuelve `valor` en una lista si no es ya una lista o tupla.
```python
fx.ensure_list(5)          # [5]
fx.ensure_list([1, 2, 3])  # [1, 2, 3]
```

**first_or_default(iterable, predicado=None, default=None)** — Devuelve el primer elemento que cumple `predicado`, o `default` si ninguno cumple, sin lanzar `StopIteration`. Funciona con cualquier iterable, incluidos generadores.
```python
fx.first_or_default([1, 2, 3, 4], lambda x: x > 2)  # 3
fx.first_or_default([], default="vacío")             # "vacío"
```

## FastList: encadenamiento de métodos

`unique_by()`, `partition()`, `partition_gt()` y algunas otras funciones devuelven un `FastList`: un subtipo de `list` que se comporta como una lista normal (indexable, iterable, con `len()`, slicing, etc.) pero además expone métodos propios para seguir encadenando sin volver a pasar por funciones sueltas del módulo.

Métodos disponibles en `FastList`:

- **`.groupby(clave)`** — igual que `fx.groupby()`, pero devuelve otro `FastList` (de pares `[clave, sublista]`) en vez de un dict, para poder seguir encadenando.
- **`.sum(campo=None)`** — sin argumento, suma los elementos como números. Con argumento, asume que el `FastList` viene de un `.groupby()` y devuelve un dict `{clave: suma_del_campo}`.
- **`.count()`** — asume que el `FastList` viene de un `.groupby()` y devuelve un dict `{clave: cantidad}`.
- **`.filter(predicado)`** — devuelve un `FastList` con los elementos donde `predicado(item)` es verdadero.
- **`.map(función)`** — devuelve un `FastList` con `función(item)` aplicada a cada elemento.
- **`.unique_by(clave)`** — igual que `fx.unique_by()`, encadenable.
- **`.flatten()`** — igual que `fx.flatten()`, encadenable.
- **`.chunk(tamaño)`** — igual que `fx.chunk()`, encadenable. Nota: el `FastList` contenedor es encadenable, pero cada sublista interna es una lista normal, no un `FastList`.
- **`.partition(predicado)`** — igual que `fx.partition()`, devuelve una tupla de dos `FastList`.
- **`.dedupe_consecutive()`** — igual que `fx.dedupe_consecutive()`, encadenable.
- **`.clip(minimo, maximo)`** *(0.3.0)* — igual que `fx.clip_outliers()`, encadenable.
- **`.rolling(tamaño)`** *(0.3.0)* — igual que `fx.rolling_window()`, encadenable.
- **`.filter_range(minimo, maximo, inclusive=True)`** *(0.3.0)* — igual que `fx.filter_range()`, sin callback, encadenable.
- **`.partition_gt(umbral)`** *(0.3.0)* — igual que `fx.partition_gt()`, sin callback, devuelve una tupla de dos `FastList`.

```python
ventas_unicas = fx.unique_by(ventas, "id")
totales = ventas_unicas.groupby("categoria").sum("monto")
# {'ropa': 240.0, 'comida': 30.0, 'tech': 500.0}
```

Encadenamientos que combinan métodos de distintas versiones en una sola expresión:

```python
# Promedio móvil real: ventana deslizante + promedio de cada ventana
precios = fx.FastList([10.0, 12.0, 11.0, 15.0, 14.0, 20.0])
promedios_moviles = precios.rolling(3).map(lambda ventana: sum(ventana) / len(ventana))
# [11.0, 12.67, 13.33, 16.33]

# Filtrar por rango sin callback, acotar outliers, y transformar — sin
# invocar Python en los dos primeros pasos
resultado = (
    fx.FastList(mediciones)
    .filter_range(5, 25)
    .clip(10, 20)
    .map(lambda n: n * 2)
)

# Separar por umbral sin callback, y seguir operando sobre cada mitad
mayores, resto = fx.FastList(valores).partition_gt(100)
top = mayores.map(lambda n: n - 100)
ventanas_del_resto = resto.rolling(5)

# Limpieza de datos: colapsar repetidos, acotar rango, filtrar
resultado = (
    fx.FastList(lecturas_sensor)
    .dedupe_consecutive()
    .clip(0, 100)
    .filter_range(0, 50)
)
```

Nota: `.sum(campo)` y `.count()` esperan específicamente el formato que produce `.groupby()` (una lista de pares `[clave, sublista]`); si se les pasa otra cosa, lanzan `TypeError` con un mensaje explicando qué esperaban.

## Ejemplo real combinando varias funciones

Sin fastcorex (19 líneas): un loop para deduplicar por id, otro para agrupar por categoría, y un tercer loop anidado para sumar montos por grupo.

Con fastcorex, dos formas equivalentes:

```python
# Con funciones sueltas (4 líneas)
ventas_unicas = fx.unique_by(ventas, "id")
grupos = fx.groupby(ventas_unicas, "categoria")
totales = {cat: fx.fast_sum([i["monto"] for i in items]) for cat, items in grupos.items()}

# Con encadenamiento de FastList (2 líneas)
ventas_unicas = fx.unique_by(ventas, "id")
totales = ventas_unicas.groupby("categoria").sum("monto")
```

Ambas versiones dan el mismo resultado: `{'ropa': 240.0, 'comida': 30.0, 'tech': 500.0}`

## Resultados de benchmark

Medido con listas de 200,000 y 2,000,000 de elementos, mejor tiempo de 5 corridas (ver `benchmark.py` en el repositorio para reproducirlo).

**Con 200,000 elementos:** fast_sum 2.5x, count_freq 1.6x, unique 2.0x, filter_gt 2.0x, groupby 1.9x, flatten 11.7x, unique_by 1.2x, chunk 1.4x, safe_get 1.1x, clamp 2.0x, pick 1.8x, omit 5.6x, deep_merge 14.0x, slugify 31.6x, dedupe_consecutive 2.2x, flatten_dict 1.3x, invert_dict 1.4x, clip_outliers 14.2x, rolling_window 2.4x, filter_range 2.5x (vs Python puro) / 7.1x (vs `.filter(lambda)`), partition_gt 3.1x (vs Python puro) / 6.2x (vs `partition(lambda)`), pipeline `unique_by→groupby→sum` 1.7x, pipeline nuevo `filter_range→clip→rolling` 3.4x. **partition: 0.51x** (más lento que Python puro; con Vectorcall desde 0.3.0, antes 0.60x — ver la explicación abajo). **pad: 0.64x** contra una función Python equivalente con la misma validación, **0.26x** contra `str.center()` desnudo sin validación — ver la explicación abajo.

**Con 2,000,000 elementos:** fast_sum 1.9x, count_freq 1.5x, unique 1.6x, filter_gt 1.6x, groupby 1.8x, flatten 8.8x, unique_by 1.2x, chunk 1.1x, safe_get 1.1x, clamp 2.0x, pick 1.7x, omit 5.9x, deep_merge 13.8x, slugify 31.5x, dedupe_consecutive 1.9x, flatten_dict 1.3x, invert_dict 1.4x, clip_outliers 13.2x, rolling_window 2.0x, filter_range 2.2x / 5.7x, partition_gt 2.3x / 4.1x, pipeline `unique_by→groupby→sum` 1.5x, pipeline nuevo 3.1x. **partition: 0.51x** (antes 0.59x en 0.3.0). **pad: 0.63x / 0.24x** (sin cambios; ver la sección 0.3.0 sobre el experimento revertido).

### Las dos especializadas de 0.3.0: filter_range y partition_gt

Estas dos son la respuesta directa a la limitación de `partition`/`.filter()` documentada abajo: cuando el "predicado" es en realidad una comparación numérica simple (un rango o un umbral), evitar el callback de Python cambia por completo el resultado. `partition_gt` pasa de la zona de "más lento que Python puro" (0.53x-0.60x, el número de `partition` con lambda) a **6.2x-4.1x más rápido que ese mismo `partition(lambda)`**, y **3.1x-2.3x más rápido que Python puro**. `filter_range` muestra el mismo patrón: **7.1x-5.7x más rápido que `.filter(lambda)`**, y **2.5x-2.2x más rápido que Python puro**. La causa es exactamente la que se sospechaba: sin el cruce Python→C→Python por elemento, el loop en C vuelve a tener la ventaja que se esperaría de una extensión nativa. El pipeline `filter_range→clip→rolling` (tres pasos, ninguno con callback) rinde 3.4x-3.1x frente a su equivalente en Python puro, frente al 0.64x-0.57x que daba el pipeline `filter→map→dedupe_consecutive` de la versión anterior (que sí tiene dos pasos con callback).

### clip_outliers y flatten_dict/invert_dict

`clip_outliers` tiene una de las mejores ganancias (14.2x-13.2x) por la misma razón que `deep_merge`: hace trabajo genuinamente pesado en C sin ningún callback, aplicando la comparación y el `PyFloat_AsDouble`/`PyFloat_FromDouble` directamente en el loop, evitando el overhead de bytecode de una list comprehension con `max(min(...))` por elemento. `flatten_dict` e `invert_dict` tienen ganancias más modestas (1.3x-1.4x) porque su costo ya está dominado por operaciones de dict que CPython ya implementa eficientemente en C por debajo (`PyDict_Next`, `PyDict_SetItem`); el beneficio ahí es sobre todo evitar escribir y mantener la recursión o el comprehension a mano, no la velocidad bruta.

### partition sigue siendo la excepción negativa real (sin cambios respecto a 0.2.0)

**partition es consistentemente ~40-41% más lento que el equivalente en Python** (0.59x-0.60x), sin cambios respecto a la versión anterior — no se tocó su implementación en esta ronda porque el problema nunca fue la implementación en sí. La razón es estructural: `partition` invoca el predicado de Python una vez por elemento vía `PyObject_CallFunctionObjArgs`, y ese cruce Python→C→Python en cada iteración cuesta más de lo que se ahorra teniendo el loop externo en C. Lo mismo aplica a `.filter()` y `.map()` de `FastList`. **La solución que aporta esta versión no es optimizar `partition` en sí (no se puede, sin cambiar qué acepta como argumento) sino ofrecer `partition_gt`/`filter_range` como alternativas sin callback para el caso — muy común en la práctica — donde el predicado es una comparación numérica simple.** Si el caso de uso necesita un predicado arbitrario de Python, `partition`/`.filter()`/`.map()` siguen siendo la única opción, y su ganancia real ahí es de expresividad y de mantener todo en una cadena legible, no de velocidad.

### pad: dos comparaciones honestas, no una

`pad` es un caso nuevo con una particularidad: el número "correcto" depende de contra qué se lo compare, y por eso el README reporta dos.

Contra `str.center()`/`str.ljust()` **desnudos**, sin ninguna validación ni soporte de `mode` unificado, `pad` es 0.24x-0.26x — notablemente más lento. Esto se investigó a fondo, no es un descuido: se probaron tres optimizaciones sucesivas (un único buffer de salida en vez de tres objetos intermedios, `PyUnicode_Fill` — la misma rutina que usa CPython internamente para estos métodos — en vez de escribir carácter por carácter, y una ruta de parseo de argumentos sin el mecanismo de keywords cuando no se pasa ninguno) y cada una redujo algo el costo pero ninguna cerró la brecha por completo. Lo que queda es el costo fijo de cruzar a través de la C API de extensión en cada llamada (empaquetar `args`, resolver el método, incrementar/decrementar referencias en el camino de llamada), que los métodos nativos de `str` evitan por estar integrados directamente en el tipo built-in con un camino de despacho más corto. Esto no tiene solución sin cambiar qué es `pad` — convertirlo en un método de `str` no es algo que una extensión externa pueda hacer.

Contra una función de Python que replique la **misma funcionalidad** de `pad` (validar que `fill` sea un solo carácter, unificar `ljust`/`rjust`/`center` bajo un parámetro `mode`, dar mensajes de error explícitos), `pad` rinde prácticamente igual: 0.63x-0.64x. Ese es el punto de comparación honesto, porque nadie que necesite esa validación va a usar `str.center()` a secas — va a escribir (o ya tiene escrita) una función wrapper con ese mismo costo de dispatch. La razón real de ser de `pad` nunca fue ganar velocidad sobre el built-in desnudo: es no tener que escribir y mantener esa función de validación uno mismo.

### Funciones sin cambios de 0.1.1/0.2.0

flatten sigue teniendo una de las mayores ganancias (11.7x-8.8x) porque en Python puro depende de recursión con overhead de llamadas a función, que en C es casi gratis. deep_merge (14.0x-13.8x) y slugify (31.6x-31.5x) mantienen sus ganancias porque hacen trabajo pesado en C sin callbacks. safe_get se mantiene en ~1.1x más rápido que Python puro (mejora que ya se documentó en la versión 0.2.0, sin cambios en esta ronda). Las funciones que ya dependen de dict/set de Python (count_freq, unique, unique_by, fast_sum) ganan menos, porque esas estructuras ya están optimizadas en C por debajo del intérprete.

## Cambios de la versión 0.3.0: auditoría de velocidad y simplificación de API

Esta versión no agrega funciones nuevas. Es una auditoría completa de las 23 funciones del módulo y los 14 métodos de `FastList`, revisando cada una en busca de (a) búsquedas o allocaciones redundantes que pudieran eliminarse sin cambiar el comportamiento observable, y (b) inconsistencias en nombres de parámetros o soporte de keywords entre funciones que resuelven problemas similares. El resultado se documenta con la misma honestidad que el resto del README: algunas mejoras son reales y medibles, otras son cambios estructuralmente correctos que no se traducen en una diferencia perceptible, y se reportan como tales en vez de inflar el número.

### Optimizaciones de velocidad

**groupby (función suelta) y FastList.groupby** son las dos mejoras con impacto medible de esta ronda. La versión anterior de `groupby` hacía `PyDict_GetItemWithError` para comprobar si el grupo ya existía, y solo en el caso de grupo nuevo agregaba un `PyDict_SetItem` adicional — es decir, para cada elemento de un grupo que ya existe, había una búsqueda "de más" en el sentido de que `PyDict_SetDefault` puede resolver "buscar o crear con un valor por defecto" en una sola operación. `FastList.groupby()` tenía una capa adicional de indirección: mantenía un `index_dict` separado (clave → índice numérico en la lista de resultado) solo para poder envolver la salida en pares `[clave, sublista]` en vez de un dict plano. Como los dicts de Python (3.7+) ya garantizan orden de inserción, esa capa de índices era innecesaria: la nueva versión usa directamente un dict `clave → [clave, sublista]` y extrae `dict.values()` al final, preservando exactamente el mismo orden de aparición que antes (verificado con un caso de categorías intercaladas de forma no trivial). Medido sobre 200,000 elementos con 5 categorías: `groupby` mejora 1.02x-1.10x, `FastList.groupby()` mejora 1.01x-1.04x, y el pipeline completo `groupby().sum()` apenas 1.01x (la mejora se diluye porque `.sum()` vuelve a recorrer todos los grupos con su propio costo, que domina el tiempo total).

**count_freq** recibió el mismo tratamiento con `PyDict_SetDefault` (antes: `PyDict_GetItemWithError` + posible `PyDict_SetItem`, dos búsquedas para una clave repetida). **flatten_dict** se ajustó para evitar la llamada a `PyObject_Str()` cuando la clave de un dict ya es un `str` (el caso inmensamente más común), comprobándolo primero con `PyUnicode_Check`. Ambos cambios son correctos y más explícitos sobre la intención del código, pero medidos repetidamente no mostraron una mejora consistente por encima del ruido de medición (~0.97x-1.03x, oscilando de corrida a corrida) — el costo real en ambos casos está dominado por el hashing de los propios objetos, no por el número de operaciones de búsqueda en el dict. Se documentan aquí en vez de callarlos porque siguen siendo una limpieza estructural válida (menos trabajo redundante en el peor caso), solo que no es una ganancia que se pueda anunciar con un número honesto.

**pad** recibió tres intentos de optimización adicionales en esta ronda, más allá de los ya aplicados en 0.3.0: un único buffer de salida (ya estaba), `PyUnicode_Fill` en vez de escribir carácter por carácter (la misma rutina que usa CPython internamente para `ljust`/`rjust`/`center`), y una ruta de parseo de argumentos que evita `PyArg_ParseTupleAndKeywords` cuando no se pasa ningún keyword. Cada uno redujo algo el costo medido de forma aislada, pero el número final frente a `str.center()` desnudo no cambió de forma significativa (sigue en la misma zona ya documentada en 0.3.0). Se investigó a fondo — incluyendo aislar cuánto cuesta específicamente pasar un argumento como keyword (hasta ~1.4x más lento que la misma llamada sin keywords, medido de forma aislada) — y la conclusión sigue siendo la misma que en 0.3.0: el costo restante es el overhead fijo de cruzar la C API de extensión en cada llamada, que no tiene solución sin convertir `pad` en un método nativo de `str`, algo que una extensión externa no puede hacer. No se revirtió ningún cambio de `pad` porque, aunque no mejoraron el número frente al built-in desnudo, tampoco lo empeoraron, y el código quedó más claro sobre qué hace cada paso.

**Se auditaron sin cambios** (ya estaban en su forma óptima, o el margen de mejora no justificaba el riesgo): `fast_sum`, `unique`/`unique_by` (el patrón `PySet_Contains` + `PySet_Add` es el estándar de C para esto; no existe una alternativa pública más barata sin acceder a símbolos internos no garantizados entre versiones de Python), `filter_gt`, `chunk`, `safe_get` (ya optimizada en 0.2.0), `clamp`, `pick`, `omit`, `deep_merge` (se evaluó evitar la copia completa de sub-dicts en cada nivel de fusión, pero el caso de uso real —configs de tamaño moderado— no muestra un costo perceptible, y la complejidad de un "copy-on-write" parcial no se justificaba), `slugify`, `partition`/`.filter()`/`.map()` (se midió que el callback de Python representa ~72% del tiempo total; el 28% restante en overhead de crecimiento de listas no compensa el riesgo de una estrategia de pre-alocación que además desperdiciaría memoria en el caso típico), `dedupe_consecutive`, `invert_dict` (no existe una función pública de la C API para pre-dimensionar un dict antes de llenarlo, a diferencia de las listas), `rolling_window`, `filter_range` y `partition_gt` (ya en su forma óptima desde 0.3.0).

### Simplificación de API

**safe_get** y **flatten_dict** renombraron su primer parámetro de `dict_obj` (un nombre idiomático de la C API interna, no de Python) a `d`, más corto y consistente con la convención usada en el resto de la documentación y los ejemplos del README. **filter_range** renombró `input_list` a `lst` por la misma razón. Estos cambios solo afectan a quien llamaba estas funciones con el nombre de keyword explícito (algo no documentado ni usado en ningún ejemplo previo del README); la forma posicional, que es la única documentada, sigue funcionando exactamente igual.

**clip_outliers** ganó soporte de keywords (`lst`, `min_val`, `max_val`), que antes no tenía pese a ser conceptualmente la función hermana más cercana de `filter_range` (ambas trabajan sobre un rango `[min, max]` de una lista de números) — antes solo se le podía pasar posicional, mientras que `filter_range` sí aceptaba nombres. Ahora `fx.clip_outliers(lista, min_val=0, max_val=10)` funciona igual que `fx.filter_range(lista, min_val=0, max_val=10)`. Se verificó que agregar el mecanismo de keywords no penalizó el caso de llamada posicional (que sigue siendo la forma más común): medido repetidamente, el ratio quedó en la misma zona de ruido que antes del cambio, sin regresión.

**Se consideró y se descartó** agregar keywords a `rolling_window` y `partition_gt`: ambas reciben solo dos argumentos (lista + un número), donde el orden es obvio y el valor de nombrarlos es mínimo — agregar esa superficie de API habría sido complejidad sin beneficio real, lo opuesto al objetivo de esta ronda. También se descartó fusionar `isinstance(value, list)` + `isinstance(value, tuple)` en `ensure_list` en una sola comprobación `isinstance(value, (list, tuple))`: parecía una simplificación razonable, pero medido directamente resultó ser ~10-20% más lento que las dos comprobaciones separadas (CPython tiene un atajo más corto para el chequeo de un único tipo que para una tupla de tipos candidatos), así que se revirtió — se prefirió el código "menos elegante" porque es el que de verdad es más rápido.

## Cambios de la versión 0.3.0: intento serio de cerrar la brecha en partition/.filter()/.map() y pad

Esta versión ataca directamente las dos únicas zonas documentadas como "igual o más lentas que Python": `partition`/`.filter()`/`.map()` (0.53x-0.60x) y `pad` (0.24x-0.64x según la comparación). El resultado es parcial y se documenta con la misma honestidad de siempre: una mejora real mantenida, un experimento que se probó y se revirtió, y un límite que sigue sin solución posible sin cambiar la API pública.

### partition, .filter() y .map(): de CallFunctionObjArgs a Vectorcall

Se reemplazó `PyObject_CallFunctionObjArgs` por `PyObject_Vectorcall` (API pública y estable desde Python 3.9, el mínimo de este proyecto) en las tres funciones que invocan un callable de Python por elemento. `Vectorcall` pasa los argumentos como un array de punteros C en vez de empaquetarlos en una tupla de Python, evitando esa construcción intermedia en cada llamada. Medido de forma aislada con una función mínima en C, esto da ~5% de mejora consistente. Medido en el contexto real de `partition()` con una lambda como predicado, sobre listas de 200,000 y 2,000,000 elementos: el ratio pasó de 0.53x-0.60x a **0.51x-0.52x** — es decir, dentro del margen de ruido, sin cambiar la conclusión de fondo. `.filter()` y `.map()` de `FastList` no mostraron ninguna mejora medible en absoluto (~0.99x-1.00x comparado contra la versión anterior). El cambio se mantiene de todas formas porque es correcto, no tiene riesgo, y no empeora nada — pero no se anuncia como la solución al problema, porque no lo es.

**El techo real, confirmado con un experimento dirigido:** se aisló específicamente cuánto cuesta invocar una lambda de Python (que crea un frame de ejecución interpretado) frente a invocar un builtin de C puro con el mismo trabajo — la lambda resultó ~39% más lenta *solo por ese motivo*, sin que ninguna API de invocación del lado de C pueda evitarlo. Este es el verdadero cuello de botella: no es cómo se invoca el callable desde la extensión, es que el callable en sí mismo es código Python interpretado. Ninguna optimización posible desde `_fastcorex.c` puede acelerar la ejecución del código Python que el usuario proporciona.

**Se descartó explícitamente** una vía más agresiva que sí se evaluó en profundidad: inspeccionar el bytecode de la lambda del usuario para detectar patrones de comparación simple (`x > N`) y resolverlos directamente en C sin invocar el protocolo de llamada. Es técnicamente posible — el bytecode de una lambda como `lambda x: x > 5` es corto y reconocible — pero se rechazó por tres razones: (1) el bytecode exacto de CPython cambia entre versiones menores de Python, lo que obligaría a mantener una tabla de compatibilidad por versión; (2) la cobertura sería limitada (no cubre closures, funciones `def`, ni `operator.gt`); y (3) el riesgo más serio: un bug sutil en la detección de patrones podría producir resultados **silenciosamente incorrectos** sin ningún error visible, un tipo de fallo mucho peor que "sigue siendo lento". El costo de mantenimiento y el riesgo de correctitud superan la ganancia, así que no se implementó.

**Hallazgo útil que sí se documenta como consejo práctico:** cuando el predicado o la función ya es un builtin de C (`bool`, `int`, `str.lower`, un método de una clase de C, etc.) en vez de una lambda de Python, ese callable no necesita crear un frame de ejecución, y el costo cae dramáticamente sin ningún cambio de código de por medio — medido: `partition(lista, bool)` es **3.56x más rápido** que `partition(lista, lambda n: bool(n))` para el mismo resultado. Esto no resuelve el caso general (una lambda con lógica arbitraria sigue pagando su propio costo), pero es una alternativa real para quien pueda expresar su condición con un builtin.

### pad: se probó METH_FASTCALL, se midió, y se revirtió

Se reescribió `pad` por completo usando `METH_FASTCALL | METH_KEYWORDS` en vez de `METH_VARARGS | METH_KEYWORDS`, extrayendo los argumentos manualmente desde un array de punteros C en vez de dejar que `PyArg_ParseTupleAndKeywords` construya y parsee una tupla. Un micro-benchmark aislado (una función mínima que solo recibe y devuelve dos argumentos) mostró **2.18x más rápido** con este mecanismo, una promesa considerable. Se implementó la reescritura completa: parseo manual de hasta 4 argumentos posicionales o con nombre en cualquier orden, detección de argumentos duplicados (posicional + keyword para el mismo parámetro), keywords desconocidos, y todos los mensajes de error que ya tenía la versión anterior — validado con una batería de 12 casos de error distintos, todos correctos, y sin leaks de memoria en ningún camino (incluido el de retorno temprano cuando el texto ya mide más que el ancho pedido).

Medido en el contexto real de `pad()` completa (no la función mínima aislada) contra la versión anterior de 0.3.0: el resultado fue **1.01x en el caso posicional y 0.97x-1.01x con el keyword `mode=`** — es decir, ninguna mejora neta perceptible. La razón: el ahorro de no construir la tupla de argumentos es una fracción minúscula del tiempo total de `pad()`, que está dominado por el resto del trabajo de la función (validar `fill`, resolver `mode`, calcular el relleno, escribir el buffer de salida) — el mismo patrón, en sentido inverso, que ya se había confirmado con `count_freq`/`flatten_dict` en la ronda 0.3.0 (una optimización teóricamente sólida que no se traduce en una ganancia medible porque el costo real está en otro lado).

Dado que la reescritura no aportó ninguna mejora real y sí agregó considerablemente más código (parseo manual de argumentos en vez de una lista `kwlist` declarativa, más superficie para bugs de mantenimiento futuro), **se revirtió por completo**: `pad` en 0.3.0 tiene exactamente la misma implementación que en 0.3.0 (`PyArg_ParseTupleAndKeywords` con la ruta rápida sin keywords ya aplicada en la ronda anterior). Los números de benchmark de `pad` en la sección anterior siguen siendo los vigentes; no cambiaron en esta ronda.

**Conclusión honesta de esta ronda:** se intentó en serio cerrar la brecha en ambos casos, con dos técnicas de la C API que en teoría debían dar mejoras sustanciales (`Vectorcall`, `METH_FASTCALL`), midiendo cada paso en vez de asumir que la teoría se traduciría en práctica. En `partition`/`.filter()`/`.map()` la mejora real quedó dentro del margen de ruido. En `pad` la mejora resultó ser cero, y el cambio se revirtió correctamente en vez de mantenerlo por inercia. Ambas funciones siguen exactamente en la misma categoría que antes de esta ronda: `partition`/`.filter()`/`.map()` con una lambda arbitraria seguirán siendo más lentas que Python puro porque el costo real es el propio código Python del usuario, no la extensión; y `pad` seguirá rindiendo por debajo de `str.center()`/`ljust()` desnudos por el costo fijo, inevitable desde una extensión externa, de cruzar la C API en cada llamada.

## Licencia

MIT License. Ver el archivo LICENSE para el texto completo.

## Estado del proyecto

Versión 0.3.. Sin funciones nuevas respecto a 0.3.0. Cubre patrones comunes de listas, diccionarios y strings (agrupar, deduplicar, aplanar y su inverso, contar, filtrar, particionar, fusionar, generar slugs, acceso anidado, acotar rangos, rellenar texto, ventanas deslizantes) más un tipo `FastList` que permite encadenar catorce operaciones distintas (`groupby`, `sum`, `count`, `filter`, `map`, `unique_by`, `flatten`, `chunk`, `partition`, `dedupe_consecutive`, `clip`, `rolling`, `filter_range`, `partition_gt`) sin pasar por dicts intermedios. No pretende reemplazar NumPy para cómputo numérico ni Pandas para análisis de datos tabulares.

Cubierto por pruebas automatizadas (`tests/`, ejecutables con `python -m unittest discover -s tests` o con `pytest tests/`): 275 pruebas en total — funciones originales de 0.1.1, 0.2.0, 0.3.0 y 0.3.0 (regresión), y una suite dedicada a la ronda 0.3.0
0 que verifica que el cambio a `Vectorcall` funciona correctamente con todo tipo de callable (lambda, función `def`, builtin, método de instancia, callable con estado) sin alterar la propagación de excepciones, y que `pad()` sigue comportándose exactamente igual tras revertir el experimento con `METH_FASTCALL`.


