Metadata-Version: 2.4
Name: fastcorex
Version: 0.4.4
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

# Texto (0.4.0)
fx.word_count("hola mundo")                 # 2
fx.find_all("abcabc", "abc")                # [0, 3]
fx.truncate("un texto largo", 10)           # "un texto..."

# Números (0.4.0)
fx.round_to(3.14159, 2)                     # 3.14
fx.random_int_fast(1, 100, 5)               # [23, 87, 4, 56, 12] (no criptográfico)
fx.lerp(0, 100, 0.5)                        # 50.0

# Fechas (0.4.0)
fx.is_weekend(fecha)                        # True/False
fx.business_days_between(f1, f2)            # días hábiles entre dos fechas
fx.format_relative(fecha)                   # "hace 3 días", "en 2 horas"

# Geometría 2D (0.4.0)
fx.distance((0, 0), (3, 4))                 # 5.0
fx.midpoint((0, 0), (10, 10))               # (5.0, 5.0)
fx.point_in_rect((5, 5), (0, 0, 10, 10))    # True
fx.point_in_circle((1, 1), (0, 0), 5)       # True

# Estadística básica (0.4.0)
fx.mean(numeros)                            # promedio
fx.median(numeros)                          # mediana
fx.stdev(numeros)                           # desviación estándar
fx.percentile(numeros, 95)                  # percentil 95
fx.mode(numeros)                            # valor más frecuente

# Variantes _many (0.4.1): más rápidas que un loop de las de arriba
fx.lerp_many(0, 100, [0.0, 0.5, 1.0])       # [0.0, 50.0, 100.0]
fx.is_weekend_many(lista_de_fechas)         # [True, False, ...]
fx.distance_many((0, 0), lista_de_puntos)   # [5.0, 3.2, ...]
fx.point_in_rect_many(lista_de_puntos, rect)  # [True, False, ...]

# Números (0.4.2): complemento de lerp para el caso inverso
fx.map_range(5, 0, 10, 0, 100)              # 50.0 (reescala entre rangos)
fx.map_range_many(valores, 0, 1023, 0, 100) # variante _many, más rápida que un loop

# Archivos (0.4.2): lecturas rápidas de texto, sin repetir with/open/encoding
fx.read_text("config.txt", default="")     # contenido completo, o default si no existe
fx.read_lines("datos.csv")                  # lista de líneas, sin saltos de línea
fx.count_lines("access.log")                # 128340 (sin cargar las líneas en memoria)
fx.peek_lines("export.jsonl", 5)            # primeras 5 líneas, sin leer el resto

# Geometría (0.4.3): cierre de una brecha nunca medida
fx.midpoint_many((0, 0), lista_de_puntos)   # variante _many de midpoint, más rápida que un loop

# Archivos: escritura (0.4.3), complemento simétrico de las lecturas de arriba
fx.write_text("salida.txt", "contenido")               # sobrescribe (o agrega con append=True)
fx.write_lines("salida.txt", ["fila1", "fila2"])        # una línea por elemento

# Datos estructurados (0.4.3): JSONL sin escribir el loop de json.loads/dumps a mano
fx.write_jsonl("eventos.jsonl", [{"id": 1}, {"id": 2}])
fx.read_jsonl("eventos.jsonl")               # [{'id': 1}, {'id': 2}]

# Archivos: listado (0.4.4)
fx.list_files("logs", suffix=".log")         # rutas completas, ya filtradas

# Diccionarios (0.4.4): complemento de escritura/borrado anidado de safe_get
fx.safe_set({}, "server.port", 8080)         # {'server': {'port': 8080}}
fx.safe_delete(cfg, "server.timeout")        # no falla si el path no existe
fx.deep_update(base, override)               # como deep_merge, pero muta 'base'

# Texto (0.4.4)
fx.replace_many("Hola mundo", [("mundo", "tierra")])  # "Hola tierra"
fx.normalize_spaces("  con   espacios  ")    # "con espacios"
```

## 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. Ante una entrada con anidamiento extremo (más de ~10,000 niveles) lanza `RecursionError` de forma segura en vez de arriesgar un desbordamiento de pila.
```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. Ante un anidamiento extremo en cualquiera de los dos argumentos, lanza `RecursionError` de forma segura en vez de arriesgar un desbordamiento de pila.
```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"
```
**Atención con texto no latino:** el conjunto de acentos que transcribe es acotado (español/portugués/francés básico). Texto completamente en otro alfabeto (cirílico, chino, árabe, etc.) no tiene ninguna transliteración definida, y cada carácter termina colapsando a separador — el resultado final es una cadena **vacía**, no un error ni una excepción.
```python
fx.slugify("Привет мир")   # '' (cadena vacía, no un error)
fx.slugify("你好世界")      # '' (cadena vacía, no un error)
```
Si el texto de entrada puede no ser latino (por ejemplo, viene de un formulario de usuario sin restricción de idioma), conviene verificar el resultado antes de usarlo como identificador: `fx.slugify(texto) or "sin-titulo"`.

**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. Ante un anidamiento extremo, lanza `RecursionError` de forma segura en vez de arriesgar un desbordamiento de pila.
```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"
```

### Texto (0.4.0)

Utilidades sobre strings que no tienen que ver con listas/dicts: útiles procesando logs, archivos, entrada de usuario, o cualquier texto en general.

**word_count(texto)** — Cuenta palabras separadas por espacio en blanco (tab, salto de línea, y separadores Unicode menos comunes incluidos), sin construir la lista intermedia que produciría `len(texto.split())`.
```python
fx.word_count("El veloz murciélago hindú")  # 4
```

**find_all(texto, patron)** — Todas las posiciones (índices base 0) donde `patron` aparece en `texto`, incluyendo coincidencias solapadas (`find_all("aaaa", "aa")` da `[0, 1, 2]`, no solo `[0, 2]`).
```python
fx.find_all("abcabcabc", "abc")  # [0, 3, 6]
```

**truncate(texto, largo, suffix="...")** — Corta `texto` a lo sumo a `largo` caracteres (incluyendo el sufijo en esa cuenta), buscando el último espacio antes del límite para no partir una palabra a la mitad. Si el texto ya mide `largo` o menos, se devuelve sin cambios.
```python
fx.truncate("Este es un texto muy largo para mostrar", 20)  # "Este es un texto..."
```

### Números (0.4.0)

**round_to(numero, decimales)** — Redondea a `decimales` posiciones, usando round-half-up (2.5 siempre redondea a 3) en vez del "banker's rounding" que usa `round()` nativo de Python (que redondea 2.5 a 2, al par más cercano).
```python
fx.round_to(2.5, 0)  # 3.0 (round() nativo daría 2.0)
```

**random_int_fast(minimo, maximo, cantidad=1)** — Uno o varios enteros aleatorios en `[minimo, maximo]` (ambos inclusive). **No es criptográficamente seguro** (usa `rand()` de C, sembrado una vez al cargar el módulo) — para eso está el módulo `secrets` de la librería estándar. Pensado para juegos, simulaciones, generación de datos de prueba, donde importa el volumen y la velocidad, no la impredecibilidad a prueba de ataques.
```python
fx.random_int_fast(1, 6)        # 4 (una tirada de dado)
fx.random_int_fast(1, 100, 1000)  # 1000 enteros aleatorios de una vez
```

**lerp(a, b, t)** — Interpolación lineal: `t=0.0` da `a`, `t=1.0` da `b`, valores intermedios dan el punto proporcional. `t` fuera de `[0, 1]` extrapola en vez de fallar (común en motores de animación). **Nota de rendimiento:** ver la sección de benchmarks — es una de las funciones donde Python puro gana cuando se llama una por una.
```python
fx.lerp(0, 100, 0.5)  # 50.0
```

**lerp_many(a, b, ts)** *(0.4.1)* — `lerp` para una lista completa de valores `t` de una sola vez: `[lerp(a, b, t) for t in ts]`, pero paga el costo fijo de cruzar la C API una sola vez para toda la lista en vez de una vez por elemento. Caso de uso real: generar muchos frames de una animación entre dos valores fijos. **Gana de forma consistente contra el equivalente en Python puro** (~3.9x-4.0x), a diferencia de `lerp` individual.
```python
fx.lerp_many(0, 100, [0.0, 0.25, 0.5, 0.75, 1.0])  # [0.0, 25.0, 50.0, 75.0, 100.0]
```

### Fechas (0.4.0)

Trabajan sobre objetos `date`/`datetime` de la librería estándar, usando la C API oficial de `datetime` en vez de reimplementar aritmética de calendario a mano.

**is_weekend(fecha)** — `True` si `fecha` (un `date` o `datetime`) cae en sábado o domingo. **Nota de rendimiento:** ver la sección de benchmarks — Python puro gana en este caso cuando se llama una por una.
```python
fx.is_weekend(date(2026, 7, 18))  # True (sábado)
```

**is_weekend_many(fechas)** *(0.4.1)* — `is_weekend` para una lista completa de fechas de una sola vez, amortizando el costo fijo de la C API entre todas ellas. Útil para marcar o filtrar un calendario completo. **Gana de forma consistente** (~3.8x-3.9x) contra el equivalente en Python puro.
```python
fx.is_weekend_many([date(2026, 7, 18), date(2026, 7, 20)])  # [True, False]
```

**business_days_between(fecha1, fecha2)** — Número de días hábiles (lunes a viernes, sin considerar feriados) entre dos fechas, inclusive en ambos extremos si caen en día hábil. Negativo si `fecha2` es anterior a `fecha1`.
```python
fx.business_days_between(date(2026, 7, 20), date(2026, 7, 24))  # 5 (lunes a viernes)
```

**format_relative(fecha)** — Descripción relativa en español de qué tan lejos está `fecha` de `datetime.now()`: `"hace 3 días"`, `"en 2 horas"`, `"justo ahora"`. Acepta `date` o `datetime`.
```python
fx.format_relative(datetime.now() - timedelta(days=3))  # "hace 3 días"
```

### Geometría 2D (0.4.0)

Convención: un punto es una tupla `(x, y)`; un rectángulo es `(x, y, ancho, alto)` con `(x, y)` como esquina superior izquierda. **Nota de rendimiento:** ver la sección de benchmarks — `distance`, `midpoint`, `point_in_rect` y `point_in_circle` llamadas una por una son casos donde Python puro gana; sus variantes `distance_many`/`point_in_rect_many` (0.4.1) sí ganan de forma clara.

**distance(p1, p2)** — Distancia euclidiana entre dos puntos.
```python
fx.distance((0, 0), (3, 4))  # 5.0
```

**distance_many(origen, puntos)** *(0.4.1)* — `distance` desde un `origen` fijo a una lista completa de puntos de una sola vez. Caso de uso real: distancia de un jugador o entidad a muchos otros puntos (detección de cercanía, ordenar por distancia). **Gana con claridad** (~7.5x-7.9x) contra el equivalente en Python puro.
```python
fx.distance_many((0, 0), [(3, 4), (0, 0), (6, 8)])  # [5.0, 0.0, 10.0]
```

**midpoint(p1, p2)** — Punto medio entre dos puntos, como tupla `(x, y)`. **Nota de rendimiento (medida por primera vez en esta ronda):** llamada suelta en un loop, pierde contra Python puro (0.83x-0.88x) — el mismo patrón ya documentado para `lerp`/`is_weekend`/`distance`/`point_in_rect`/`map_range`. Usar `midpoint_many()` para muchos puntos a la vez.
```python
fx.midpoint((0, 0), (10, 10))  # (5.0, 5.0)
```

**midpoint_many(origen, puntos)** *(0.4.3)* — `midpoint()` entre `origen` y cada punto de `puntos`, amortizando el costo fijo de cruzar la C API — mismo patrón que `distance_many()` frente a `distance()`. **Gana con claridad** (1.45x-1.83x).
```python
fx.midpoint_many((0, 0), [(10, 10), (20, 0), (0, 20)])  # [(5.0, 5.0), (10.0, 0.0), (0.0, 10.0)]
```

**point_in_rect(punto, rect)** — `True` si `punto` cae dentro de `rect`, bordes inclusive.
```python
fx.point_in_rect((5, 5), (0, 0, 10, 10))  # True
```

**point_in_rect_many(puntos, rect)** *(0.4.1)* — `point_in_rect` para una lista completa de puntos de una sola vez. Nota de orden: el "sujeto" (los puntos) va primero, igual que en `point_in_rect(punto, rect)`. Caso de uso real: filtrar qué elementos de una colección caen dentro de una zona (área visible en un juego, región de selección). **Gana con claridad** (~5.9x-6.0x).
```python
fx.point_in_rect_many([(5, 5), (15, 5), (10, 10)], (0, 0, 10, 10))  # [True, False, True]
```

**point_in_circle(punto, centro, radio)** — `True` si `punto` cae dentro (o justo en el borde) del círculo de `centro` y `radio`.
```python
fx.point_in_circle((1, 1), (0, 0), 5)  # True
```

### Estadística básica (0.4.0)

Medidas de tendencia central y dispersión sobre listas de números. **Nota de rendimiento:** el resultado varía según la función y la distribución de los datos — ver la sección de benchmarks para el detalle completo antes de asumir que todas ganan por igual.

**mean(lista)** — Promedio aritmético.
```python
fx.mean([1, 2, 3, 4, 5])  # 3.0
```

**median(lista)** — Mediana; con cantidad par de elementos, promedio de los dos valores centrales.
```python
fx.median([1, 2, 3, 4, 5])  # 3.0
```

**stdev(lista)** — Desviación estándar **poblacional** (divide por `n`, no por `n-1`). Para la varianza muestral, usar `statistics.stdev()` de la librería estándar.
```python
fx.stdev([2, 4, 4, 4, 5, 5, 7, 9])  # 2.0
```

**percentile(lista, p)** — Valor del percentil `p` (0-100), con interpolación lineal entre los dos valores más cercanos (el mismo método que usa `numpy.percentile` por defecto).
```python
fx.percentile([1, 2, 3, 4, 5], 95)  # 4.8
```

**mode(lista)** — El valor que más veces aparece. Acepta cualquier tipo hasheable, no solo números. En caso de empate, devuelve el primero que alcanzó la frecuencia máxima.
```python
fx.mode([1, 2, 2, 3, 3, 3, 4])  # 3
```

### Números (0.4.2)

**map_range(valor, in_min, in_max, out_min, out_max)** — Reescala `valor` linealmente de `[in_min, in_max]` a `[out_min, out_max]`. Es el complemento natural de `lerp()`: `lerp()` va de un `t` en `[0, 1]` a un rango arbitrario; `map_range()` va de un rango arbitrario a otro rango arbitrario, sin pasar por `[0, 1]` como paso intermedio. `valor` fuera de `[in_min, in_max]` extrapola en vez de fallar, igual que `lerp()` con `t` fuera de `[0, 1]`. Lanza `ValueError` si `in_min == in_max` (división por cero). **Nota de rendimiento:** llamada suelta en un loop, es una operación tan trivial del lado de Python que **pierde** contra el equivalente en Python puro (medido: 0.76x-0.79x) — el mismo patrón ya documentado para `lerp`/`is_weekend`/`distance`/`point_in_rect`. Usar `map_range_many()` para muchos valores a la vez.
```python
fx.map_range(5, 0, 10, 0, 100)     # 50.0
fx.map_range(-5, 0, 10, 0, 100)    # -50.0 (extrapola)
```

**map_range_many(valores, in_min, in_max, out_min, out_max)** *(0.4.2)* — `map_range()` sobre una lista completa de valores de una sola vez, amortizando el costo fijo de cruzar la C API entre todos los elementos — el mismo patrón que `lerp_many()` frente a `lerp()`. **Gana con claridad** (4.5x-4.6x más rápida que el equivalente en un loop de Python). Caso de uso real: normalizar lecturas de un sensor (por ejemplo, un ADC de 10 bits, rango 0-1023) a un porcentaje.
```python
fx.map_range_many([10, 250, 512, 800, 1023], 0, 1023, 0, 100)
# [0.98, 24.44, 50.05, 78.20, 100.0]
```

### Archivos (0.4.2)

Lecturas rápidas de archivos de texto. No reemplazan `pathlib` ni el propio `open()`: no manejan modos binarios, y no sustituyen ningún caso donde ya se necesite control fino sobre el manejo del archivo (streaming genuino sin cargar nada en memoria, modos de apertura especiales). Cubren el caso puntual de "necesito el contenido (o las líneas, o solo un conteo, o solo un vistazo) de un archivo de texto sin repetir el mismo bloque `with open(...) as f: ...` cada vez". Todas aceptan tanto `str` como cualquier `os.PathLike` (`pathlib.Path` incluido) como ruta — el mismo comportamiento que `open()`.

**read_text(path, default=None, encoding="utf-8")** — Contenido completo del archivo como `str`. Si el archivo no existe, devuelve `default` en vez de lanzar `FileNotFoundError` — pero cualquier otro error del sistema de archivos (permisos, `path` es un directorio, etc.) sí se propaga como excepción normalmente; `default` cubre específicamente "puede que este archivo todavía no exista", no cualquier fallo de lectura. **Gana en todos los perfiles medidos** (1.0x-1.6x; se acerca a 1x con archivos grandes, más alto con archivos chicos).
```python
fx.read_text("config.txt", default="")       # '' si config.txt no existe
fx.read_text("datos.csv", encoding="latin-1")  # con encoding explícito
```

**read_lines(path, strip=True)** — Lista con cada línea del archivo como un elemento `str`, sin el salto de línea final. Con `strip=True` (por defecto) también recorta espacios en blanco al principio/final de cada línea (equivalente a `.strip()`); con `strip=False`, solo quita el salto de línea. Un archivo vacío devuelve `[]`, igual que `list(open(path))`. **El resultado depende del largo de línea, no es una ganancia garantizada:** gana de forma modesta con líneas cortas (~1.2x) pero **pierde** con líneas largas (~0.86x-0.9x) — ver la sección de benchmarks para el detalle y la causa.
```python
fx.read_lines("datos.csv")               # ['fila1,a,b', 'fila2,c,d', ...]
fx.read_lines("datos.csv", strip=False)  # conserva espacios internos/de borde
```

**count_lines(path)** — Número de líneas del archivo, sin materializar ninguna de ellas como objeto Python (no crea un `str` de Python por línea, a diferencia de `sum(1 for _ in open(path))`). Una última línea sin salto de línea final pero con contenido cuenta como línea, igual que el equivalente en Python puro. Un archivo vacío cuenta `0` líneas. **Es la única de las cuatro funciones de lectura que gana en todos los perfiles de datos medidos** (2.0x-5.5x, más con líneas cortas que con líneas largas).
```python
fx.count_lines("access.log")  # 128340
```

**peek_lines(path, n)** — Primeras `n` líneas del archivo, sin leer el resto; útil para inspeccionar archivos grandes (logs, exports, datasets) sin cargar el contenido completo. `n=0` devuelve `[]`; `n` mayor a la cantidad total de líneas devuelve todas las que haya, sin error. **El resultado depende del valor de `n`:** gana con claridad para `n` chico (2.3x-3.8x con `n=1` o `n=10`, donde corta la lectura casi de inmediato), pero **pierde** para `n` más grande (0.65x-0.84x con `n=100` o `n=1000`, donde el loop de lectura byte a byte en C empieza a perder contra el iterador ya optimizado de Python) — no es la función a elegir esperando una ganancia de rendimiento garantizada para cualquier `n`, sino por la comodidad de no leer el archivo completo cuando `n` es efectivamente chico frente al archivo.
```python
fx.peek_lines("export.jsonl", 5)  # primeras 5 líneas, ninguna más
```

### Archivos: escritura (0.4.3)

Complemento simétrico de las lecturas de arriba: hasta esta ronda, fastcorex solo podía leer archivos, nunca escribirlos. Mismo criterio de rutas (`str` u `os.PathLike`) y de manejo de errores de OS que las funciones de lectura.

**write_text(path, content, append=False, encoding="utf-8")** — Escribe `content` (str) al archivo completo, sobrescribiendo si ya existe (o agregando al final con `append=True`). El contenido se codifica *antes* de abrir el archivo: si `content` tiene caracteres no representables en `encoding` (`UnicodeEncodeError`), el archivo no se toca — ni se crea vacío, ni se trunca uno que ya existía. **El resultado es cercano a 1x, variable entre corridas** (0.95x-2.11x en una batería de 10 mediciones) — a diferencia de la primera medición de esta ronda, que por casualidad cayó en el extremo favorable del rango real y sugería una ganancia más firme de la que sostienen más muestras.
```python
fx.write_text("salida.txt", "contenido nuevo")            # sobrescribe
fx.write_text("log.txt", "nueva entrada\n", append=True)  # agrega al final
```

**write_lines(path, lines, append=False, encoding="utf-8")** — Escribe cada elemento de `lines` (lista de `str`) como una línea del archivo, con un `\n` entre cada una (nunca uno de más al final de la lista, ni uno de menos entre elementos — el archivo resultante es indistinguible de `"\n".join(lines)` escrito directamente). Una lista vacía da un archivo vacío, no uno con un solo salto de línea. **El resultado mide cerca de 1x, con variación real entre corridas que a veces cruza a pérdida** (0.90x-1.55x, según el perfil de datos) — internamente ya usa la misma función C que usa el patrón `"\n".join()` de Python (`PyUnicode_Join`), así que no hay margen real para ganarle de forma consistente. El valor de esta función no es superar a `join()`, es no tener que saber que `join()` es la forma rápida de escribirlo (el patrón que escribe la mayoría de la gente, un `f.write()` por línea en un loop, es 2.7x más lento que `join()`, medido).
```python
fx.write_lines("salida.txt", ["fila1", "fila2", "fila3"])
```

### Datos estructurados (0.4.3)

**read_jsonl(path)** / **write_jsonl(path, objects, append=False)** — Leer y escribir archivos JSONL/NDJSON (un objeto JSON por línea), sin escribir el loop de `json.loads()`/`json.dumps()` con el manejo de líneas vacías a mano. `read_jsonl` ignora en silencio líneas vacías o de solo espacios (convención estándar de JSONL); `write_jsonl` escribe con `ensure_ascii=False`, así que texto no-ASCII (acentos, símbolos, cualquier idioma fuera de ASCII) queda en UTF-8 real en el archivo en vez de como escapes `\uXXXX`. Ambas requieren `import json` de todos modos — no es una dependencia externa nueva, solo evitan escribir el loop.

**Nota de rendimiento, la más importante de esta ronda: estas dos funciones NO ganan velocidad de forma notable, y eso es intencional documentarlo así.** `json.loads()`/`json.dumps()` ya invocan el parser en C de la librería estándar (módulo `_json`); el trabajo pesado de parsear o serializar cada línea es C de cualquier forma, esté fastcorex de por medio o no. Medido antes de escribir una sola línea de C (con un prototipo) y confirmado después con el código real: **`read_jsonl` 0.93x-1.04x, `write_jsonl` 0.95x-1.08x — ambas prácticamente empate** contra Python puro. El valor real de `read_jsonl`/`write_jsonl` es exclusivamente de conveniencia — no tener que escribir el loop con el manejo de líneas vacías, ni acordarse de pasar `ensure_ascii=False` — no una ganancia de velocidad, y se documentan así desde el principio en vez de forzar un número que no es real.
```python
fx.write_jsonl("eventos.jsonl", [{"id": 1, "tipo": "click"}, {"id": 2, "tipo": "view"}])
fx.read_jsonl("eventos.jsonl")  # [{'id': 1, 'tipo': 'click'}, {'id': 2, 'tipo': 'view'}]
```

### Archivos: listado (0.4.4)

**list_files(directorio, suffix=None)** — Rutas completas (str) de los archivos y subdirectorios directos de `directorio` (no recursivo). Con `suffix`, solo incluye entradas cuyo nombre termine en ese sufijo (por ejemplo `".log"`). Internamente invoca `os.scandir()` desde C — deliberadamente no usa la API nativa de listado de directorios de C (`dirent.h` en POSIX), porque esa API no existe en Windows y hubiera roto la compilación ahí sin un `#ifdef` dedicado; `os.scandir()` ya resuelve esa diferencia de plataforma dentro de CPython. **El resultado depende de con qué se compare, y las dos comparaciones cuentan historias distintas:** gana con claridad (1.10x-1.27x) contra el patrón que escribe la mayoría de la gente sin pensarlo (`os.listdir(d)` + `os.path.join(d, f)` por cada nombre + `f.endswith()` manual en el loop) — exactamente el patrón del caso de uso que motivó esta función. Pero **pierde** por un margen chico (0.91x-0.98x) contra `os.scandir(directorio)` usado directamente con una comprehension de Python (`entry.path` ya evita el join, `entry.name` ya evita un segundo `os.path.basename`) — quien ya conoce ese patrón no gana nada usando `list_files()` en su lugar. El valor real de esta función es no tener que saber que `os.scandir()` (en vez de `os.listdir()`) es la forma correcta y rápida de listar con rutas completas.
```python
fx.list_files("logs")                # todo: archivos y subdirectorios directos
fx.list_files("logs", suffix=".log") # solo los que terminan en .log
```

### Diccionarios (0.4.4)

**safe_set(d, "a.b.c", valor)** — Asigna `valor` en el path anidado, mutando `d` en el lugar y devolviendo el mismo `d`. Los niveles intermedios faltantes se crean como dicts nuevos automáticamente (equivalente a `setdefault()` encadenado); si un nivel intermedio ya existe pero **no** es un dict, se lanza `TypeError` en vez de sobreescribirlo en silencio — perder ese valor sin avisar sería peor que fallar. **Gana** (1.00x-2.74x, con variación real entre corridas) evitando la lista intermedia que crea `path.split(".")` en Python.
```python
fx.safe_set({}, "server.port", 8080)  # {'server': {'port': 8080}}
```

**safe_delete(d, "a.b.c")** — Borra la clave final del path si existe, mutando `d` en el lugar y devolviendo el mismo `d`. Si cualquier segmento intermedio del path no existe (o dejó de ser un dict), la función no hace nada y no lanza — "seguro" significa que un path parcialmente inexistente es un no-op, no un error, igual que `dict.pop(k, None)` no falla si `k` no está. **Gana** de forma modesta pero consistente (1.03x-1.24x).
```python
fx.safe_delete(cfg, "server.timeout")  # no-op silencioso si el path no existe
```

**deep_update(base, override)** — Como `deep_merge()`, pero fusiona `override` en `base` **mutando `base` en el lugar** en vez de devolver un dict nuevo (`deep_merge()` nunca toca ninguno de sus dos argumentos). `override` nunca se modifica en ningún caso. **Mide prácticamente empate** (0.96x-1.11x) contra Python puro y contra `deep_merge()` + reasignar — su valor es semántico (mutar en el lugar cuando eso es lo que hace falta, por ejemplo actualizar una config que otra parte del código ya referencia por identidad), no una ganancia de velocidad.
```python
fx.deep_update(base, {"servidor": {"puerto": 9090}})  # base queda mutado
```

### Texto (0.4.4)

**replace_many(texto, pares)** — Aplica todos los reemplazos de `pares` (lista de tuplas `(buscar, reemplazar)`) **en una sola pasada** sobre `texto`. Esto es intencionalmente distinto de encadenar `texto.replace(a, b).replace(c, d)`: cada posición del texto original se reemplaza como máximo una vez, así que el resultado de un reemplazo nunca vuelve a matchear contra otro par de la lista (sin "efecto cascada"). Ejemplo concreto: `replace_many("aaa", [("a","b"), ("b","c")])` da `"bbb"`, **no** `"ccc"` — encadenar dos `.replace()` sí daría `"ccc"`. Esta semántica de una sola pasada es la misma que ya usa `re.sub("a|b", ...)` con un patrón combinado, no un comportamiento inventado para esta función. Si dos patrones podrían matchear en la misma posición (por ejemplo `"a"` y `"ab"` al inicio de `"abc"`), gana el que aparece primero en `pares` — otra vez, la misma regla que ya sigue `re.sub` con alternancia. Ningún patrón de búsqueda puede ser el string vacío (lanza `ValueError`): un patrón vacío "matchearía" en cada posición sin avanzar, lo que en una implementación ingenua entra en loop infinito — confirmado durante el desarrollo con un prototipo que efectivamente colgaba. **Gana con claridad** (1.24x-1.88x contra `re.sub`, mejor con más pares de reemplazo).
```python
fx.replace_many("Hola mundo", [("mundo", "tierra")])       # "Hola tierra"
fx.replace_many("aaa", [("a", "b"), ("b", "c")])            # "bbb", no "ccc"
```

**normalize_spaces(texto)** — Colapsa cualquier secuencia de espacios en blanco (espacios, tabs, saltos de línea) a un único espacio simple, y recorta los extremos. Equivalente a `" ".join(texto.split())`, el patrón idiomático de Python (que ya es ~6x más rápido que la alternativa con `re.sub(r"\s+", " ", texto).strip()`, así que ese es el baseline real contra el que se mide, no la regex). **Gana** (1.19x-2.26x) escribiendo directo al buffer de salida, evitando la lista intermedia de fragmentos que crea `texto.split()` internamente.
```python
fx.normalize_spaces("  con   espacios  \n\textra")  # "con espacios extra"
```

## 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`.
- **`.lerp_to(a, b)`** *(0.4.1)* — trata `self` como una lista de valores `t` e interpola cada uno entre `a` y `b`, encadenable.
- **`.map_to(in_min, in_max, out_min, out_max)`** *(0.4.2)* — trata `self` como una lista de valores de entrada y reescala cada uno de `[in_min, in_max]` a `[out_min, out_max]`, encadenable. Se llama `map_to` y no `map_range` para no chocar con `.map()` (que aplica una función callback arbitraria).
- **`.is_weekend()`** *(0.4.1)* — trata `self` como una lista de fechas y marca cuáles caen en fin de semana, encadenable.
- **`.distance_to(origen)`** *(0.4.1)* — trata `self` como una lista de puntos y calcula la distancia de cada uno a `origen`, encadenable.
- **`.inside_rect(rect)`** *(0.4.1)* — trata `self` como una lista de puntos y marca cuáles caen dentro de `rect`, encadenable.
- **`.write_jsonl(path, append=False)`** *(0.4.3)* — escribe `self` como archivo JSONL y devuelve **`self`**, no un `FastList` nuevo — a diferencia de los demás métodos de esta lista, esta operación es un efecto secundario (escribir a disco), no una transformación, así que no hay ninguna lista distinta que envolver. Devuelve `self` en vez de `None` específicamente para no cortar una cadena de encadenamiento: `fx.FastList(datos).filter_range(0, 100).write_jsonl("checkpoint.jsonl").map(procesar)` guarda un checkpoint intermedio y sigue procesando en la misma línea.
```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
# Puntos cercanos a un origen, en una sola cadena de dos pasos
puntos = fx.FastList([(1.0, 1.0), (50.0, 50.0), (2.0, 2.0), (100.0, 100.0)])
cercanos = puntos.distance_to((0, 0)).filter_range(0, 10)

# Contar fines de semana en un rango de fechas
from datetime import date, timedelta
fechas = fx.FastList([date(2026, 7, 20) + timedelta(days=i) for i in range(14)])
cantidad_fines_de_semana = sum(fechas.is_weekend())

# Normalizar lecturas de un sensor (rango 0-1023) a porcentaje, y
# quedarse solo con las que superan un umbral — sin invocar Python
# por elemento en ninguno de los dos pasos
sensores = fx.FastList([10, 250, 512, 800, 1023])
altas = sensores.map_to(0, 1023, 0, 100).filter_range(50, 100)
# [50.05, 78.2, 100.0]

# Filtrar, guardar un checkpoint en disco, y seguir procesando en la
# misma línea — write_jsonl() no corta la cadena porque devuelve self
registros = fx.FastList([{"id": 1, "valor": 10}, {"id": 2, "valor": 55}, {"id": 3, "valor": 90}])
ids_relevantes = (
    registros.filter(lambda d: d["valor"] > 20)
    .write_jsonl("checkpoint.jsonl")  # guarda [{'id': 2, ...}, {'id': 3, ...}] y devuelve self
    .map(lambda d: d["id"])
)
# [2, 3]


# 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.2, 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.1). **pad: 0.63x / 0.24x** (sin cambios; ver la sección 0.3.2 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.1: 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.2: 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.1: 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.1 (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.2 tiene exactamente la misma implementación que en 0.3.1 (`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.

## Cambios de la versión 0.3.3: robustez ante casos borde

Esta versión no toca velocidad ni API pública; se centró en encontrar y corregir comportamiento incorrecto o inseguro ante entradas extremas: recursión profunda, overflow de enteros, valores especiales de punto flotante, y Unicode fuera de lo común. El hallazgo principal es serio y se corrigió: tres funciones podían crashear el proceso Python completo.

### El hallazgo principal: tres funciones podían producir un segmentation fault

`flatten()`, `deep_merge()` y `flatten_dict()` recursan una vez por cada nivel de anidamiento de su entrada, sin ningún límite. Se verificó empíricamente que una lista o dict con suficiente anidamiento —entre 150,000 y 200,000 niveles, dependiendo de la función— desborda la pila de llamadas de C y produce un **segmentation fault que mata el proceso Python entero**, con código de salida 139 (SIGSEGV) en dos casos y un "Killed" por out-of-memory (código 137) en el tercero (`flatten_dict`, porque cada nivel construye un prefijo cada vez más largo con `PyUnicode_FromFormat` antes de llegar al límite de pila). En ningún caso hay una excepción capturable: el proceso simplemente termina, sin que ningún `try/except` del código que llama pueda hacer nada al respecto.

Esto es explotable con datos no confiables: cualquier programa que llame estas funciones sobre JSON deserializado de una fuente externa, o sobre estructuras construidas a partir de entrada de usuario, puede ver su proceso completo morir si esa entrada tiene anidamiento adversarial — sin que el error se registre como una excepción, sin traceback, y potencialmente sin ningún log más allá de lo que el sistema operativo reporte sobre la señal recibida.

**La corrección:** se envolvió el cuerpo de las tres funciones recursivas (`flatten_into`, `deep_merge_into`, `flatten_dict_into`) con `Py_EnterRecursiveCall()`/`Py_LeaveRecursiveCall()`, el mecanismo oficial y público de la C API de CPython para esto exacto. `Py_EnterRecursiveCall` lleva la cuenta de la profundidad de recursión de C y, al acercarse al límite (el mismo que usa `sys.setrecursionlimit()` para la propia pila de Python, aunque con un margen distinto para C — medido empíricamente en ~9,970 niveles para estas funciones, sensiblemente antes del punto real de segfault), deja pendiente un `RecursionError` normal de Python en vez de seguir recursando hasta reventar la pila real. Verificado explícitamente: tras capturar el `RecursionError`, el intérprete queda en un estado completamente normal y se puede seguir llamando a cualquier función de `fastcorex` sin ningún problema — no es un estado de "corrupción parcial" del que haya que recuperarse de alguna forma especial.

El costo de esta protección es, en la práctica, cero: medido sobre un caso de anidamiento normal (unos pocos niveles, como cualquier estructura de datos real), el ratio entre la versión con protección y sin ella fue de 0.979x — dentro del ruido de medición, sin ninguna penalización perceptible. `Py_EnterRecursiveCall` es, en esencia, un incremento y una comparación de un contador entero; el costo real de la recursión (crear el frame de C, las variables locales, etc.) es el mismo con o sin la protección.

### Overflow de enteros

Los parámetros numéricos que el usuario controla directamente (`width` en `pad`, el tamaño en `chunk`/`rolling_window`) ya estaban protegidos sin necesidad de ningún cambio: usan el formato `"n"` de `PyArg_ParseTuple`, que internamente mapea a `Py_ssize_t` y **ya lanza `OverflowError`** de forma nativa si el entero de Python pasado no cabe en ese tipo — verificado explícitamente con enteros de 100 dígitos (`10**100`), muy por encima de cualquier entero de 64 bits. `count_freq` sí se ajustó: usaba `PyLong_AsLong`/`PyLong_FromLong` (basados en `long`, que en Windows de 64 bits es de solo 32 bits) para el conteo de repeticiones, cuando el conteo real nunca puede superar el tamaño de la lista de entrada (ya acotado por `Py_ssize_t`, consistentemente de 64 bits en cualquier plataforma moderna). Se cambió a `PyLong_AsSsize_t`/`PyLong_FromSsize_t`, evitando un límite artificialmente más bajo en esa plataforma específica sin ningún costo adicional.

### Valores especiales de punto flotante (NaN, infinito)

Se auditaron las siete funciones que convierten a `double` (`fast_sum`, `filter_gt`, `clip_outliers`, `filter_range`, `partition_gt`, y las dos variantes de `FastList.sum()`) con NaN e infinito en distintas combinaciones. En todos los casos el comportamiento coincide exactamente con el equivalente en Python puro, porque las comparaciones (`>`, `<`, `==`) sobre `double` en C siguen el mismo estándar IEEE 754 que usa CPython internamente para `float` — no hay ninguna divergencia que corregir. Este resultado, aunque no requirió ningún cambio de código, se documenta con pruebas explícitas para que quede registrado y protegido contra una futura regresión accidental (por ejemplo, alguien podría "optimizar" una comparación de forma que accidentalmente trate NaN de forma distinta).

### Unicode extremo

Se probó `slugify` y `pad` con emojis del plano astral, caracteres combinantes, múltiples alfabetos (cirílico, chino, árabe), caracteres de control, y emojis compuestos de varios code points (como los emojis de familia, que en realidad son 7 code points de Python unidos con zero-width joiners). Todos los casos se comportan de forma seria y predecible, con un único hallazgo real que se documenta explícitamente en el README y en el docstring del módulo: `slugify()` sobre texto completamente en un alfabeto no cubierto por su tabla de transliteración (que solo cubre acentos latinos comunes) da como resultado una **cadena vacía**, no un error. Esto no es un bug — es consistente con la propia regla de la función (cualquier carácter no reconocido colapsa a separador, y los separadores se colapsan y recortan al final) — pero antes no estaba documentado con la claridad suficiente, y una cadena vacía silenciosa puede ser peligrosa si se usa como identificador único (por ejemplo, un nombre de archivo o slug de URL). `pad` correctamente rechaza con `ValueError` cualquier `fill` que mida más de un code point de Python, incluidos los emojis compuestos, que es el comportamiento correcto aunque visualmente parezcan "un solo carácter".

## Cambios de la versión 0.4.0: cinco áreas nuevas fuera de listas y diccionarios

Hasta la versión 0.3.3, `fastcorex` solo cubría patrones de listas y diccionarios. Esta versión agrega 18 funciones nuevas en 5 áreas que no tocan listas ni dicts: texto, números, fechas, geometría 2D, y estadística básica — pensadas para que la librería sirva en contextos donde antes no aportaba nada (juegos, reportes, validación de formularios, cualquier script que no procese datos tabulares).

No todas las 18 funciones nuevas son más rápidas que su equivalente en Python puro, y este README lo documenta con la misma honestidad que el resto del proyecto en vez de promediar el resultado o mostrar solo los casos favorables.

### Lo que gana de forma clara y consistente

`word_count` (1.50x-1.73x), `find_all` (3.05x-3.20x), `truncate` (1.79x-1.85x), `round_to` (1.90x-2.00x), `random_int_fast` (13.55x-14.17x) y `business_days_between` (127.96x-164.46x) ganan de forma consistente en ambos tamaños medidos (200,000 y 2,000,000 elementos donde aplica). `business_days_between` tiene la ganancia más grande de esta ronda porque la comparación en Python puro más directa (iterar día por día construyendo un objeto `date` nuevo en cada paso) es genuinamente costosa; la versión en C solo hace aritmética modular sobre el offset de día de la semana, sin construir ningún objeto de fecha intermedio.

### Lo que pierde contra Python puro, y por qué (el mismo patrón ya visto con pad/partition)

**lerp** (0.23x-0.25x), **is_weekend** (0.42x-0.44x), **distance** (0.46x) y **point_in_rect** (0.64x-0.65x) son más lentas que Python puro. La razón es la misma que ya se documentó extensamente para `pad` y `partition` en rondas anteriores: son operaciones tan triviales del lado de Python (una expresión aritmética directa, o un desempaquetado de tupla seguido de una comparación) que el costo fijo de cruzar la C API — parsear los argumentos, empaquetar el resultado como objeto Python — pesa más que el cómputo en sí.

`is_weekend` en particular se investigó y se corrigió parcialmente durante esta misma ronda: la primera implementación invocaba `.weekday()` de Python desde dentro de C (vía `PyObject_CallMethod`), pagando el cruce a la C API **dos veces** — una para invocar el método, otra para el resultado — cuando el equivalente en Python puro paga ese costo una sola vez. Se reemplazó por un cálculo directo del día de la semana en C (algoritmo de Sakamoto, verificado exhaustivamente contra `date.weekday()` real en 7,200 fechas distintas a lo largo de 200 años, incluyendo años bisiestos y el cambio de siglo, con 0 discrepancias), lo que mejoró el ratio de 0.20x a 0.42x-0.44x — casi el triple de rápido, pero sin cruzar 1x, porque sigue quedando un cruce a la C API para una comparación que en Python ya es prácticamente gratis.

Para `distance`/`point_in_rect`/`midpoint`/`point_in_circle` se investigó una vía adicional: reemplazar la función auxiliar genérica de parseo de puntos (`parse_point`, que usa `PySequence_Fast`) por el formato anidado `"(dd)(dd)"` de `PyArg_ParseTuple`, que en teoría delega el desempaquetado directamente a la maquinaria ya optimizada de la C API. Medido de forma aislada, esta alternativa resultó **más lenta** que la implementación genérica actual (0.70x), no más rápida — se descartó por no aportar ninguna mejora real. La conclusión, consistente con `lerp`/`is_weekend`/`pad`/`partition`: cuando la operación equivalente en Python es una expresión directa sin llamadas a función, ninguna vía de la C API le gana, porque el costo fijo de cruzar esa API es mayor que el cómputo que se está acelerando.

### Estadística: el resultado depende de la distribución de los datos

`mean` y `stdev` ganan de forma aplastante contra `statistics.mean`/`statistics.pstdev` de la librería estándar (75x-96x en las mediciones iniciales) porque ese módulo prioriza precisión exacta con aritmética de fracciones sobre velocidad. Pero contra la forma más directa posible en Python (`sum(lista)/len(lista)`), `mean` rinde prácticamente igual (1.01x-1.02x) — la ganancia real frente a `statistics` no es "C es más rápido que cualquier código Python", es "C es más rápido que priorizar precisión exacta sobre velocidad".

`median` y `percentile` (que ambas ordenan los datos con `qsort` de C antes de calcular el resultado) mostraron algo más sutil durante la medición: el resultado depende genuinamente de la **distribución** de los datos, no solo de su tamaño. Con datos aleatorios de tamaño moderado o grande (a partir de aproximadamente 5,000 elementos, verificado con una búsqueda por tamaños), ganan de forma consistente (1.13x-1.39x con datos aleatorios de 20,000 a 2,000,000 de elementos) porque ordenar un array de `double` crudos en C evita el overhead de comparar objetos `PyObject*` que paga `sorted()` de Python. Pero con datos **muy repetitivos** (pocos valores únicos repetidos muchas veces — el caso típico de puntajes discretos, categorías codificadas como número, o mediciones redondeadas), el resultado se invierte: Timsort de Python detecta y aprovecha las corridas repetidas de forma mucho más eficiente que un `qsort` genérico, y en ese escenario Python puro gana (0.74x-0.76x, medido explícitamente con los mismos 10 valores repetidos 2000 veces). `mode()` tiene una particularidad adicional: su caso de uso típico es precisamente sobre datos con muchas repeticiones (si no hubiera repeticiones, pedir "el valor más frecuente" no tendría sentido), así que el escenario donde `mode()` pierde contra `statistics.mode()` (0.74x-0.80x) es, irónicamente, el escenario más realista para esa función específica.

**Recomendación práctica:** si los datos que se van a medir son mediciones continuas y variadas (tiempos de respuesta, precios, coordenadas), `median`/`percentile` de fastcorex son la opción más rápida a partir de unos pocos miles de elementos. Si los datos tienen pocos valores únicos muy repetidos (categorías, puntajes discretos, calificaciones), la versión de Python puro (`sorted()` + indexado, o `statistics.mode()`) puede ser más rápida — vale la pena medir el caso propio antes de asumir que fastcorex siempre gana.

## Cambios de la versión 0.4.1: variantes _many para lo que perdía contra Python, más encadenamiento y una simplificación de API

Esta versión ataca directamente las cuatro funciones de 0.4.0 documentadas como más lentas que Python puro cuando se llaman una por una: `lerp`, `is_weekend`, `distance`, `point_in_rect`. En vez de repetir las mismas técnicas de invocación de la C API ya agotadas en rondas anteriores (`Vectorcall` en 0.3.2, `METH_FASTCALL` en el experimento revertido de `pad`), que ya se confirmó experimentalmente que no pueden cerrar la brecha cuando la operación equivalente en Python es una expresión directa sin llamadas a función, se probó una vía distinta: aceptar una **lista completa** de valores en una sola llamada, amortizando el costo fijo de cruzar la C API entre muchos elementos en vez de pagarlo una vez por cada uno.

### Las 4 variantes _many: la hipótesis se confirmó con claridad

Antes de construir las cuatro, se validó la hipótesis con un solo caso (`lerp_many`, medido con 1000 valores de `t`): dio 3.92x más rápido que el equivalente en Python puro. Confirmada la hipótesis, se construyeron las cuatro: `lerp_many(a, b, ts)`, `is_weekend_many(fechas)`, `distance_many(origen, puntos)` y `point_in_rect_many(puntos, rect)`. Medidas con el benchmark completo (listas de 1000 elementos, sobre bases de 200,000 y 2,000,000): `lerp_many` 3.89x-4.01x, `is_weekend_many` 3.77x-3.93x, `distance_many` 7.55x-7.87x, `point_in_rect_many` 5.91x-6.00x — las cuatro ganan de forma clara y consistente, revirtiendo por completo el resultado negativo de sus contrapartes individuales.

Esto no contradice lo aprendido en rondas anteriores sobre `pad`/`partition`/`lerp`/`is_weekend`/`distance`/`point_in_rect` individuales: el límite de fondo (que ninguna técnica de invocación de la C API puede acelerar una operación que en Python ya es casi gratis) sigue siendo válido *para la llamada individual*. Lo que cambia con `_many` es la pregunta: ya no se está comparando "una llamada a C" contra "una expresión de Python", se está comparando "una llamada a C que procesa N elementos" contra "N expresiones de Python en un loop" — y ahí el costo fijo de la C API, que antes se pagaba N veces, ahora se paga una sola vez, mientras que el trabajo real (interpolar, calcular día de la semana, distancia euclidiana, comparación de rango) sigue siendo comparable en ambos lados.

### Encadenamiento: 4 métodos nuevos de FastList

Las cuatro variantes `_many` se envuelven además como métodos de `FastList` (`.lerp_to(a, b)`, `.is_weekend()`, `.distance_to(origen)`, `.inside_rect(rect)`), tratando `self` como la lista de valores/fechas/puntos correspondiente. Esto permite combinarlas con el resto de métodos ya existentes en una sola cadena, que es el objetivo central de esta ronda además de la velocidad: por ejemplo, `puntos.distance_to((0, 0)).filter_range(0, 10)` encuentra en dos pasos encadenados los puntos dentro de un radio dado, sin variables intermedias ni un loop explícito. Las cuatro funciones internas se refactorizaron en el patrón `_impl`/wrapper que ya usaba el resto del proyecto (por ejemplo `filter_range_impl`/`filter_range`) específicamente para que la lógica pudiera reutilizarse entre la función suelta del módulo y el método de `FastList` sin duplicar código.

### Simplificación de API: un orden de argumentos inconsistente, corregido

Al revisar las cuatro funciones nuevas para el encadenamiento, se detectó una inconsistencia real: `point_in_rect_many` tenía el orden `(rect, puntos)`, invertido respecto a `point_in_rect(punto, rect)` — alguien que ya supiera usar la función singular hubiera intentado naturalmente `point_in_rect_many(puntos, rect)` por analogía, y se habría encontrado con el orden equivocado. Se corrigió a `(puntos, rect)`, consistente con el convenio de "el sujeto primero, el rectángulo después" que ya seguía `point_in_rect`. Este es un cambio de comportamiento (rompe cualquier código que ya hubiera llamado `point_in_rect_many` con el orden viejo), pero como la función se introdujo en esta misma sesión de trabajo y nunca llegó a documentarse ni empaquetarse con el orden anterior, el riesgo real de romper código de terceros es nulo. Las otras tres variantes (`lerp_many`, `is_weekend_many`, `distance_many`) ya eran consistentes con sus funciones singulares desde el diseño inicial y no necesitaron cambios de orden.

## Cambios de la versión 0.4.2: archivos, un complemento de lerp, y una auditoría de robustez sin hallazgos nuevos

Dos áreas nuevas (`map_range`/`map_range_many` en números, y las cuatro funciones de archivo) más una auditoría dirigida de robustez sobre el código existente de `FastList` y las variantes `_many` de 0.4.1.

### Auditoría de robustez: se revisó, no se encontró un defecto real

Antes de tocar código nuevo se auditaron `FastList`, las 4 funciones `_many` de 0.4.1, `parse_point`/`parse_rect`, `business_days_between` y `format_relative` buscando específicamente los patrones de bugs ya encontrados en la auditoría de 0.3.3 (overflow de enteros, valores especiales de punto flotante, casos borde de tipos de entrada). Se probaron varias hipótesis contra el código real: si las funciones `_many` rechazaban tuplas de forma inconsistente entre sí (no — es el comportamiento establecido en todo el módulo, verificado contra `fast_sum`, `filter_gt`, `chunk` y otras siete funciones más), si `business_days_between`/`format_relative` podían desbordar un `long` de 32 bits con fechas extremas (no — el rango máximo posible de `date` de la librería estándar, año 1 a 9999, acota el delta a ~3.65 millones de días, muy por debajo del límite de ~2.1 mil millones), y si el loop día-por-día de `business_days_between` tenía un costo inaceptable en el caso extremo (no — 8.9ms para el rango máximo posible entre `date.min` y `date.max`). **Conclusión honesta: no se encontró un defecto real en esta ronda.** Se documenta la auditoría igual, siguiendo el mismo criterio que ya usó la versión 0.3.1 para las funciones que se revisaron sin necesitar cambios: decir "se auditó, no se encontraron problemas" en vez de inventar un cambio para tener algo que reportar.

### map_range / map_range_many: el mismo patrón de lerp, resuelto en la misma ronda

`map_range` es el complemento natural de `lerp()` que faltaba: `lerp(a, b, t)` va de un `t` en `[0, 1]` a un rango arbitrario; no había forma de ir de un rango arbitrario a otro sin escribir a mano el patrón de dos líneas (`t = (valor - in_min) / (in_max - in_min)`; `resultado = out_min + t * (out_max - out_min)`) que se repite en proyectos de audio, gráficos, UI o lectura de sensores. Se midió antes de escribir cualquier código C: la versión individual, llamada suelta en un loop, pierde contra Python puro (0.76x-0.79x) — exactamente el resultado esperable dado que es la misma clase de operación aritmética trivial que `lerp`. A diferencia de la ronda 0.4.0 (que documentó esa pérdida para cuatro funciones y esperó a la ronda 0.4.1 para la solución), acá se construyó `map_range_many` en la misma sesión en la que se detectó el problema, siguiendo el patrón `_impl`/wrapper que ya usa `lerp_many`/`lerp_many_impl` para poder reutilizar la lógica también desde `FastList.map_to()`. Mide 4.5x-4.6x más rápida que el equivalente en un loop de Python — consistente con el resto de las variantes `_many` de 0.4.1.

### Archivos: read_text, read_lines, count_lines, peek_lines

Cuatro funciones para el caso de "lectura rápida completa o acotada" de un archivo de texto, sin repetir el bloque `with open(path, encoding=...) as f: ...` en cada proyecto. Todas aceptan `str` o cualquier `os.PathLike` como ruta (vía `PyUnicode_FSConverter`, el mismo convertidor que usa `open()` internamente), y los errores del sistema de archivos salen con el mismo subtipo específico de `OSError` que usaría `open()` para el mismo error (`FileNotFoundError`, `IsADirectoryError`, `PermissionError`) en vez de un `OSError` genérico, porque se usa `PyErr_SetFromErrnoWithFilenameObject`, que selecciona el subtipo a partir de `errno` de la misma forma que lo hace el propio intérprete.

`read_text` acepta un parámetro `default` que se devuelve en vez de lanzar `FileNotFoundError` cuando el archivo no existe — pensado específicamente para el caso de "puede que este archivo todavía no exista" (un archivo de configuración opcional, un cache), no para silenciar cualquier error de lectura: un directorio pasado por error, o un problema de permisos, sí se siguen propagando como excepción normalmente.

**Un bug real se detectó y corrigió durante las pruebas de esta ronda:** `read_lines` sobre un archivo vacío devolvía `['']` en vez de `[]`. La causa fue una condición en el loop de C que distinguía mal "archivo vacío" de "archivo que termina en un salto de línea" — ambos casos comparten que el último segmento del loop está vacío, pero solo el segundo debe descartarse como línea fantasma. Se detectó comparando sistemáticamente el resultado contra `[línea.strip() for línea in open(path)]` en nueve archivos de prueba distintos (archivo normal, sin salto final, vacío, con espacios, una sola línea sin salto, finales de línea estilo Windows, Unicode, y dos casos límite adicionales de solo saltos de línea), no por inspección del código — la inspección del código, de hecho, hizo pensar que la condición ya escrita era correcta. Corregido y cubierto con un test dedicado (`test_archivo_vacio_da_lista_vacia`) para que no vuelva a pasar desapercibido.

**Un segundo hallazgo, esta vez de rendimiento y no de correctitud, casi queda sin documentar por depender de una sola generación de datos de prueba.** La primera medición de `read_lines` (con un generador de líneas cortas tipo CSV) dio una ganancia modesta y estable. Al construir el script de benchmark reproducible que acompaña esta versión, con un segundo perfil de datos más realista para el caso de uso típico de un log (líneas más largas, con timestamp y mensaje completo), la misma función midió una **pérdida** neta contra Python puro, también estable en corridas repetidas. `peek_lines` tiene una dependencia análoga, pero sobre el valor de `n` en vez del largo de línea: gana con `n` chico, pierde con `n` grande. Ninguna de las dos funciones se reescribió a partir de este hallazgo — el resultado es real y depende genuinamente del caso de uso, no de un error de implementación — pero si esta ronda solo hubiera medido con el primer generador de datos, el README habría reportado una ganancia donde en realidad hay una pérdida real en un caso de uso común. Queda documentado en la tabla de benchmarks con ambos perfiles en vez de un solo número.

`count_lines` es la ganancia de rendimiento más consistente del grupo — la única que gana en ambos perfiles de datos medidos (2.0x-5.5x, más con líneas cortas que con líneas largas) porque es la única de las cuatro que evita por completo crear un objeto `str` de Python por línea, contando bytes `\n` directamente sobre un buffer de C. `peek_lines` corta la lectura apenas junta las `n` líneas pedidas, sin leer el resto del archivo (verificado explícitamente: una línea con bytes UTF-8 inválidos después de la línea `n` no afecta el resultado, porque nunca se llega a decodificarla) — pero, a diferencia de `count_lines`, su ganancia de rendimiento depende del valor de `n`: gana con claridad para `n` chico y pierde para `n` grande (ver la tabla de benchmarks a continuación).

### Resultados de benchmark de esta ronda

Medido con `timeit.repeat(repeat=5, number=1)` (mejor de 5 corridas), igual metodología que el resto del README. Reproducible con `python benchmark_v042_files_and_maprange.py` (genera sus propios archivos de prueba en un directorio temporal; no se incluyen en el repositorio por tamaño). Para `map_range`/`map_range_many`, sobre una lista de 100,000 valores. Para las funciones de archivo, sobre dos archivos de 1,000,000 de líneas cada uno con perfiles distintos — uno de líneas cortas (~15 caracteres, tipo CSV, 16 MB) y uno de líneas largas (~65 caracteres, tipo log completo, 58 MB) — porque, como se detalló arriba, el ratio de algunas de estas funciones depende sustancialmente de cuál de los dos perfiles se mida, no solo de la cantidad de líneas.

| Función | Ratio | Notas |
|---|---|---|
| `map_range` (individual, en un loop) | **0.76x-0.79x** | Pierde — mismo patrón que `lerp`/`is_weekend`/`distance`/`point_in_rect` |
| `map_range_many` (batched) | **~4.5x-4.6x** | Gana con claridad, estable en corridas repetidas |
| `count_lines` | **2.0x-5.5x** | Gana siempre; más con líneas cortas, menos con líneas largas |
| `read_text` | **1.0x-1.6x** | Gana siempre, pero se acerca a 1x con archivos grandes |
| `read_lines` | **0.86x-1.23x** | **Depende del largo de línea: gana con líneas cortas, pierde con líneas largas** |
| `peek_lines(n)` | **0.65x-3.8x** | **Depende de `n`: gana con `n` chico, pierde con `n` grande** |

**El caso más importante de esta tabla es `read_lines`, y casi queda documentado con un solo número engañoso.** La primera medición (con un generador de datos de líneas cortas) dio 1.04x-1.25x, una ganancia modesta pero real. Al construir el script de benchmark reproducible para el README — con un segundo perfil de datos más realista para logs (líneas de ~65 caracteres) — la misma función midió **0.86x-0.90x: una pérdida**, estable en corridas repetidas. La causa es que la implementación en C decodifica cada línea con `PyUnicode_DecodeUTF8` por segmento; para líneas cortas eso es comparable al costo del iterador de líneas de Python, pero para líneas más largas el costo de decodificar cada segmento por separado empieza a superar lo que se ahorra evitando el `readline()` interno de Python. **Conclusión práctica: si el archivo tiene líneas largas (logs con timestamps y mensajes completos, JSON por línea, texto libre), medir el caso propio antes de asumir que `read_lines` acelera algo — puede que no.** `read_text`, en cambio, no tiene este problema: gana en ambos perfiles (aunque se acerca a 1x con archivos grandes), porque no hace ningún trabajo por línea.

`peek_lines` tiene el mismo tipo de dependencia, pero sobre `n` en vez del largo de línea. Con `n` chico (1, 10) gana con claridad (2.3x-3.8x) porque corta la lectura casi de inmediato y evita casi todo el overhead de Python; con `n` más grande (100, 1000) **pierde** (0.65x-0.84x), porque a esa escala el costo por línea del loop de `fgetc()` byte a byte en C empieza a perder contra el iterador de líneas ya optimizado de Python. La función sigue siendo útil por la razón original (no leer el resto del archivo), pero no como una apuesta segura de velocidad para cualquier valor de `n`.

`map_range` repite el hallazgo de 0.4.0/0.4.1, y esta ronda no repite el error de esperar una versión más para solucionarlo: es la misma operación aritmética trivial que `lerp` (dos multiplicaciones y una división), así que pierde por la misma razón — el costo fijo de cruzar la C API pesa más que el cómputo cuando se llama una vez por elemento. La diferencia con la ronda 0.4.0 es que ahí se documentó la pérdida y se esperó a 0.4.1 para agregar las variantes `_many`; acá, habiendo ya validado la hipótesis con `lerp_many` en la ronda anterior, se construyó `map_range_many` en la misma sesión de trabajo en la que se detectó la pérdida.

### Verificación: sin fugas de memoria, sin regresiones

Cada una de las 6 funciones nuevas y el método `FastList.map_to()` se sometieron a una prueba de estrés de al menos 20,000 iteraciones combinando la ruta de éxito con cada ruta de error posible (archivo ausente, directorio en vez de archivo, tipo de ruta inválido, elemento no numérico a mitad de lista), midiendo `RSS` antes y después: **0 KB de crecimiento** en todos los casos. La suite completa de 440 pruebas de las versiones 0.1.1-0.4.1 se corrió sin modificar antes y después de cada cambio de esta ronda, sin una sola regresión.

## Cambios de la versión 0.4.3: cierre de una brecha nunca medida, escritura de archivos, y una categoría nueva para datos por línea

Tres focos: cerrar una brecha de rendimiento real que había quedado sin medir desde 0.4.0 (`midpoint`), agregar la escritura de archivos como complemento simétrico de las lecturas de 0.4.2 (hasta esta ronda, fastcorex solo podía leer), y una categoría nueva para el caso puntual de datos estructurados línea por línea (JSONL), con la conclusión honesta de que esa categoría no aporta velocidad — solo conveniencia.

### midpoint_many: la brecha que quedó sin medir en 0.4.0/0.4.1

Al revisar qué funciones de geometría de 0.4.0 nunca recibieron una variante `_many` (a diferencia de `distance`, `is_weekend`, `point_in_rect`, que sí la tienen desde 0.4.1), aparecieron dos candidatas: `midpoint` y `point_in_circle`. Se midieron ambas antes de decidir nada: `point_in_circle` individual gana levemente (1.10x) — no hay brecha real que cerrar ahí. `midpoint` individual **pierde** (0.83x-0.88x, confirmado estable en corridas repetidas) y nunca se había medido ni documentado hasta ahora, pese a ser exactamente el mismo tipo de operación aritmética trivial que ya perdía en `lerp`/`is_weekend`/`distance`/`point_in_rect`/`map_range`. Se agregó `midpoint_many(origen, puntos)`, con la misma firma que `distance_many(origen, puntos)` por consistencia, siguiendo el remedio ya establecido. Mide 1.45x-1.83x más rápida que el equivalente en un loop de Python.

### write_text / write_lines: complemento simétrico de las lecturas de 0.4.2

Hasta esta ronda, fastcorex tenía cuatro funciones de lectura de archivos (`read_text`, `read_lines`, `count_lines`, `peek_lines`) pero ninguna de escritura — una asimetría real. `write_text` y `write_lines` cierran esa brecha con el mismo criterio de manejo de rutas y errores que las funciones de lectura ya establecidas.

Una decisión de diseño deliberada en ambas: el contenido se codifica (o, en el caso de `write_lines`, se une con `"\n".join()` internamente) **antes** de abrir el archivo en modo escritura. Esto significa que si `write_text` recibe un encoding que no puede representar el contenido (`UnicodeEncodeError`), o si `write_lines` recibe un elemento que no es `str` (`TypeError`), el archivo de destino no se toca en absoluto — ni se crea vacío si no existía, ni se trunca si ya tenía contenido. Verificado explícitamente con pruebas dedicadas para ambos casos (`test_error_de_encoding_no_toca_archivo_existente`, `test_elemento_no_str_lanza_typeerror_y_no_crea_archivo`), no solo asumido por el orden de las operaciones en el código.

Ambas funciones miden cerca de 1x, no la ganancia consistente que sugerían las primeras corridas de esta ronda: `write_text` en una batería de 10 mediciones dio 0.95x-2.11x (la primera medición, 2.11x, resultó ser el extremo favorable de una distribución bastante más ancha, no lo típico), y `write_lines` dio 0.90x-1.55x, con variación real que en algunas corridas cruza a pérdida neta. La razón en ambos casos es la misma: la implementación en C usa la misma maquinaria que ya usa CPython internamente (`PyUnicode_AsEncodedString` para `write_text`, `PyUnicode_Join` para `write_lines` — la función C que hay detrás de `"\n".join(lineas)` escrito a mano en Python) — así que no hay margen real para ganarle de forma consistente a alguien que ya conoce el patrón óptimo. El valor real de estas dos funciones no es superar a lo que ya hace Python internamente, es no tener que saber cuál es el patrón rápido para beneficiarse de él: el patrón que escribe la mayoría de la gente sin pensarlo para líneas (`for linea in lineas: f.write(linea + "\n")`) mide 2.7x más lento que `join()`, así que ambas funciones siguen cerrando una brecha real — de hábito, no de rendimiento contra el mejor patrón de Python puro.

### read_jsonl / write_jsonl: categoría nueva, con una conclusión honesta sobre su límite

JSONL (un objeto JSON por línea) es un formato común para logs, eventos, exports y datasets, y el patrón que se repite a mano en cada proyecto (`import json` + un loop con `json.loads()` por línea + manejo de líneas vacías) es exactamente candidato a cerrar, tal como se pidió explícitamente para esta ronda.

**Antes de escribir una sola línea de C, se midió con un prototipo si había margen real de velocidad.** El resultado: combinar lectura de líneas en C con `json.loads()` de Python sobre cada una midió prácticamente empate (~1.03x) contra el equivalente enteramente en Python puro. La razón, verificada y no solo asumida: el módulo `json` de la librería estándar ya invoca su propio parser en C (módulo `_json`) tanto para `loads()` como para `dumps()`, así que el trabajo pesado — parsear o serializar cada línea — es C de cualquier forma, esté fastcorex de por medio o no. No hay una brecha de velocidad genuina que cerrar ahí, sin importar qué tan cuidadosamente se escriba la función en C.

Con esa conclusión ya sobre la mesa, se decidió construir `read_jsonl`/`write_jsonl` igual — pero documentadas desde el principio como funciones de **conveniencia**, no de rendimiento, en vez de forzar una narrativa de velocidad que los números no sostienen. Confirmado con el código real: `read_jsonl` 0.93x-1.04x, `write_jsonl` 0.95x-1.08x, ambas dentro del margen de empate. `write_jsonl` usa `ensure_ascii=False` por defecto, así que texto no-ASCII queda en UTF-8 real en el archivo en vez de como escapes `\uXXXX` — una diferencia de comportamiento respecto a `json.dumps()` sin argumentos (que sí escapa por defecto), elegida deliberadamente porque el caso de uso típico de JSONL (logs, eventos, datasets) no se beneficia de escapar caracteres que ya son válidos en UTF-8, y el archivo resultante es más legible y liviano.

**Se descartó CSV como candidato para esta ronda**, pese a ser tan común como JSONL. Dos razones concretas: Python ya trae `csv.DictReader` en la librería estándar (a diferencia de JSONL, "no depender de una librería externa" ya se cumple hoy sin fastcorex), y se midió que el patrón manual (`zip(header, fila)` construido a mano) es más rápido que `DictReader`, no más lento — no hay ganancia de velocidad que ofrecer sobre el patrón que la gente ya escribe. Cerrar esa brecha de verdad requeriría reimplementar un parser CSV completo en C (comillas, escapes, campos multilínea), un trabajo sustancialmente mayor y con más superficie de bugs que las demás funciones de esta ronda — se prefirió no prometer una utilidad "fácil" que en realidad no lo es.

### Verificación: una fuga aparente que resultó ser costo de import, no una fuga real

La primera prueba de estrés de memoria para `read_jsonl`/`write_jsonl` (20,000 iteraciones combinando éxito y las rutas de error) midió 292 KB de crecimiento — a diferencia de las demás funciones de esta ronda, que dieron 0 KB. En vez de descartarlo como ruido o darlo por bueno, se investigó: repitiendo la misma prueba en 5 tandas sucesivas de 20,000 llamadas cada una, el crecimiento ocurrió *únicamente* en la primera tanda (0 KB en las cuatro siguientes, 80,000 llamadas adicionales sin ningún crecimiento). Comparado contra el costo de memoria de un `import json` en un proceso limpio (~1 MB), la conclusión es que se trata del costo de cargar el módulo `json` una sola vez la primera vez que se invoca — el mismo costo que pagaría cualquier script que hiciera `import json` de forma perezosa — no una fuga de referencia por llamada. Se documenta acá el proceso de verificación completo, no solo la conclusión, porque la primera medición sola sí parecía indicar un problema real.

### Resultados de benchmark de esta ronda

Medido con `timeit.repeat(repeat=5, number=1)` (mejor de 5 corridas), igual metodología que el resto del README. Reproducible con `python benchmark_v043_write_and_jsonl.py`.

| Función | Ratio | Notas |
|---|---|---|
| `midpoint_many` | **1.45x-1.83x** | Gana con claridad, estable |
| `write_text` | **0.95x-2.11x** | Cerca de 1x, variable — no es una ganancia garantizada |
| `write_lines` | **0.90x-1.55x** | Cerca de 1x, variable — a veces pierde; ya usa la misma función C que `join()` |
| `read_jsonl` | **0.93x-1.04x** | Prácticamente empate — función de conveniencia, no de velocidad |
| `write_jsonl` | **0.95x-1.08x** | Prácticamente empate — función de conveniencia, no de velocidad |

`FastList.write_jsonl()` no aparece en la tabla por separado: internamente llama exactamente al mismo código que `write_jsonl()` de módulo (comparten la misma función `_impl`), así que medirla por separado solo confirmaría el mismo número — medido de todos modos para verificar esa afirmación en vez de asumirla: la diferencia entre ambas es 0.2%, dentro del ruido de medición.

## Cambios de la versión 0.4.4: brechas dentro de áreas que ya existían, y cuatro candidatos descartados con evidencia

El objetivo de esta ronda fue distinto de las anteriores: no una categoría nueva, sino cerrar brechas puntuales dentro de áreas que fastcorex ya cubría (archivos, diccionarios, texto) pero donde todavía faltaba una función para un flujo de uso completo. Seis funciones nuevas se implementaron y se quedaron; otras cuatro se implementaron completas y se descartaron con evidencia medida antes de llegar al código final — esa segunda mitad se documenta acá con el mismo detalle que la primera, porque el trabajo de medir y descartar es tan real como el de las funciones que sí se quedaron.

### list_files: multiplataforma por diseño, con las dos caras del número documentadas

`list_files(directorio, suffix=None)` invoca `os.scandir()` desde C en vez de usar `dirent.h` (la API nativa de listado de directorios en POSIX) directamente. La razón es concreta: `dirent.h` no existe en Windows (que usa la API Win32 `FindFirstFile`/`FindNextFile`, una interfaz completamente distinta), y el propio código de este módulo ya se preocupa por compatibilidad con Windows en otros lugares (ver la nota sobre `PyLong_AsLong` de 32 bits en la auditoría de 0.4.2). Escribir dos rutas de código con `#ifdef` para cada plataforma era más trabajo del que ameritaba esta ronda, así que se optó por invocar `os.scandir()` — que ya resuelve esa diferencia de plataforma dentro de CPython — siguiendo el mismo criterio que ya usa este módulo para `json`/`datetime`.

Medido con dos comparaciones distintas a propósito, porque cuentan historias distintas: gana con claridad (1.10x-1.27x) contra el patrón que escribe la mayoría de la gente sin pensarlo, pero pierde por un margen chico (0.91x-0.98x) contra `os.scandir()` usado directamente por alguien que ya conoce ese patrón. Se documentan ambos números en la tabla de benchmarks, no solo el favorable.

### safe_set / safe_delete: complemento de escritura y borrado de safe_get, con un bug real corregido

`safe_get()` solo lee. `safe_set()` y `safe_delete()` cierran el flujo completo de trabajar con dicts anidados vía un path de puntos. Ambas ganan evitando la lista intermedia que crea `path.split(".")` en Python — el mismo motivo por el que `safe_get()` ya ganaba desde su implementación original.

**Un bug real se detectó y corrigió durante esta ronda:** `safe_delete()` con un nivel intermedio que existe pero no es un dict (por ejemplo `{"server": "no soy un dict"}` con el path `"server.port"`) producía un `SystemError` de bajo nivel de CPython — no una excepción de Python manejable con `try/except`, sino un fallo de la propia C API. La causa: el chequeo `PyDict_Check(current)` estaba en la rama de código que cubre los segmentos intermedios del path, pero no en la rama del último segmento — y ese último segmento es exactamente donde el crash ocurría, porque `PyDict_DelItem()` recibía un string como primer argumento en vez de un dict. Se detectó escribiendo deliberadamente ese caso límite como parte de las pruebas de esta ronda, no por casualidad — la lección concreta es que "seguro" en el nombre de una función no es automático, cada rama de código tiene que probarse explícitamente para sostenerlo. Corregido moviendo el chequeo de tipo antes de la bifurcación entre segmento intermedio y segmento final, con una prueba dedicada (`test_nivel_intermedio_no_dict_no_crashea`) para que no vuelva a pasar desapercibido.

### deep_update: mismo trabajo que deep_merge, con una diferencia puramente semántica

`deep_update(base, override)` reutiliza directamente el helper interno `deep_merge_into()` que ya usa `deep_merge()` sobre una copia — la única diferencia de implementación es no copiar `base` primero. Se midió explícitamente si evitar esa copia inicial ahorraba algo real (comparando `deep_update()` contra `deep_merge()` + reasignar el resultado): la diferencia resultó estar dentro del margen de ruido (0.98x-1.11x), porque el propio `deep_merge_into()` ya copia los sub-dicts que efectivamente cambian, así que el costo dominante es el mismo trabajo recursivo en ambos casos. El valor de `deep_update()` no es de rendimiento — es semántico: mutar `base` en el lugar cuando eso es lo que hace falta (por ejemplo, actualizar una configuración que otra parte del código ya referencia por identidad y necesita ver el cambio reflejado sin reasignar la variable).

### replace_many: una semántica deliberadamente distinta de encadenar .replace(), con un loop infinito real evitado

`replace_many(texto, pares)` aplica varios reemplazos en una sola pasada sobre el texto, con una semántica que se decidió explícitamente **antes** de escribir cualquier código: sin efecto cascada. Encadenar `texto.replace(a, b).replace(c, d)` puede producir un resultado donde el segundo reemplazo vuelve a operar sobre el resultado del primero (`"aaa"` con `a→b` luego `b→c` da `"ccc"`); `replace_many()` en cambio reemplaza cada posición del texto original como máximo una vez (el mismo caso da `"bbb"`). Esta no es una decisión arbitraria: es exactamente la misma semántica que ya tiene `re.sub("a|b", ...)` con un patrón combinado — no un comportamiento inventado para esta función, sino uno que ya existe en la librería estándar para este caso, así que medirla contra `re.sub` (no contra el patrón encadenado, que es una semántica distinta) es la comparación correcta.

**Un problema real se detectó con un prototipo, antes de escribir el código de producción:** un patrón de búsqueda vacío (`""`) hacía que la implementación entrara en loop infinito — confirmado ejecutando el prototipo bajo un timeout, que efectivamente cortó el proceso colgado. `str.replace("", x)` y `re.sub("", x, ...)` sí manejan ese caso (insertan el reemplazo entre cada carácter del texto), pero replicar ese comportamiento no aporta nada al caso de uso real de esta función (reemplazar varias palabras o frases), así que se decidió rechazar explícitamente cualquier patrón de búsqueda vacío con `ValueError` en vez de replicar la inserción-entre-caracteres.

Verificado contra `re.sub` con 200 pruebas aleatorias usando un alfabeto deliberadamente chico (para maximizar solapamientos y casos de ambigüedad entre patrones), todas coincidentes, además de los casos puntuales pensados de antemano (semántica sin cascada, orden de desempate, Unicode). Medido con el código real de producción (no el prototipo simplificado que solo manejaba ASCII, que daba un número más favorable): 1.24x-1.88x contra `re.sub`, mejor cuantos más pares de reemplazo se pasan.

### normalize_spaces: evitar la lista intermedia de split()

`normalize_spaces(texto)` escribe directamente al buffer de salida en una sola pasada, evitando la lista intermedia de fragmentos que crea `texto.split()` internamente antes de que `" ".join()` los una de vuelta. Medido contra ese patrón idiomático de Python (que ya es ~6x más rápido que la alternativa con `re.sub(r"\s+", " ", texto).strip()`, así que ese — no la regex — es el baseline real): 1.19x-2.26x, ganando en todas las muestras medidas.

### Cuatro candidatos implementados y descartados con evidencia, no por intuición

**sort_by(lista, clave)** se implementó completo: tuplas `(clave, índice, elemento)` para evitar comparar dicts entre sí en un empate (comparar dos dicts lanza `TypeError`), con un segundo bug real de estabilidad detectado y corregido durante el desarrollo — con `reverse=True`, el índice de desempate también necesitaba negarse, o el orden relativo de elementos con la misma clave quedaba invertido respecto a `sorted(key=..., reverse=True)` (confirmado con una batería de 40 comparaciones aleatorias diseñadas para maximizar empates). Pese a ese trabajo, medido contra `sorted(key=itemgetter(...))` — el patrón ya óptimo en Python puro, que en sí mismo ya es 1.48x más rápido que la lambda ingenua — la función perdía consistentemente 0.18x-0.23x: casi 5x más lenta, no más rápida, en 8 muestras sin una sola excepción. La causa: construir las tuplas decoradas en C tiene más costo de creación de objetos Python del que ahorra evitando la invocación de `itemgetter` por comparación. Se eliminó del código por completo — no solo se dejó de documentar — porque una función que activamente empeora el patrón que ya existe en la librería estándar no tiene motivo para llevar el nombre de este proyecto.

**reduce(fn, lista)** se descartó antes de escribir una sola línea de C: se midió que el 97.9% del costo total de `functools.reduce()` con una lambda es invocar esa lambda, no el propio loop — algo que ninguna función C puede evitar cuando el callback es arbitrario y provisto por quien llama. El margen máximo teórico de mover el loop a C, calculado explícitamente, era del 2.1% del tiempo total: insuficiente para justificar la función, y probablemente se perdería igual por el costo fijo de cruzar la C API.

**take(lista, n)** y **drop(lista, n)** se descartaron por un motivo distinto: `lista[:n]` ya es una de las operaciones más baratas posibles en CPython (copia solo los punteros del segmento pedido, sin tocar el resto de la lista), así que no queda margen real para que una función C le gane. A diferencia de `lerp()`/`map_range()`, que sí perdían individualmente pero tenían un remedio real en la variante `_many` (procesar muchas entradas de una vez), `take`/`drop` no tienen un análogo posible: ya operan sobre la lista completa en una sola llamada, no hay "muchas invocaciones" que batchear.

**line_count(texto)** (contar líneas de un string ya en memoria, distinto de `count_lines()` que opera sobre un archivo en disco) se descartó con un prototipo aislado: perdía de forma estable 0.71x-0.76x contra `texto.count("\n")` en 8 muestras sin excepción, porque `str.count()` de CPython ya usa un algoritmo de búsqueda de substring más optimizado que un loop carácter-por-carácter en C — el mismo tipo de resultado que ya se había visto con `sort_by()` frente a `sorted()`: cuando la operación ya está resuelta con una función C muy afinada en la librería estándar, agregar una capa C por encima rara vez gana.

### Resultados de benchmark de esta ronda

Medido con `timeit.repeat(repeat=5, number=1)` (mejor de 5 corridas), igual metodología que el resto del README. Reproducible con `python benchmark_v044_gaps_in_existing_areas.py`.

| Función | Ratio | Notas |
|---|---|---|
| `list_files` (vs patrón ingenuo) | **1.10x-1.27x** | Gana con claridad contra el patrón que la gente ya escribe |
| `list_files` (vs `os.scandir` directo) | **0.91x-0.98x** | Pierde por un margen chico contra quien ya conoce el patrón óptimo |
| `safe_set` | **1.00x-2.74x** | Gana, con variación real entre corridas |
| `safe_delete` | **1.03x-1.24x** | Gana de forma modesta pero consistente |
| `deep_update` | **0.96x-1.11x** | Prácticamente empate — valor semántico, no de velocidad |
| `replace_many` | **1.24x-1.88x** | Gana con claridad contra `re.sub`, mejor con más pares |
| `normalize_spaces` | **1.19x-2.26x** | Gana en todas las muestras medidas |

## Licencia

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

## Estado del proyecto

Versión 0.4.4. Cubre patrones de listas, diccionarios y strings (de las versiones 0.1.x-0.3.x) más siete áreas que amplían el alcance más allá de datos tabulares: texto (`word_count`, `find_all`, `truncate`, `replace_many`, `normalize_spaces`), números de uso general (`round_to`, `random_int_fast`, `lerp`, `lerp_many`, `map_range`, `map_range_many`), fechas (`is_weekend`, `is_weekend_many`, `business_days_between`, `format_relative`), geometría 2D (`distance`, `distance_many`, `midpoint`, `midpoint_many`, `point_in_rect`, `point_in_rect_many`, `point_in_circle`), estadística básica (`mean`, `median`, `stdev`, `percentile`, `mode`), archivos (`read_text`, `read_lines`, `count_lines`, `peek_lines`, `write_text`, `write_lines`, `list_files`), y datos estructurados (`read_jsonl`, `write_jsonl`) — más un complemento de escritura/borrado anidado para diccionarios (`safe_set`, `safe_delete`, `deep_update`, junto a `safe_get`/`deep_merge` ya existentes desde 0.2.0). El tipo `FastList` permite encadenar veinte operaciones sobre listas, dicts, fechas y puntos sin pasar por estructuras intermedias — `.write_jsonl()` es la única que no transforma la lista sino que la escribe a disco como efecto secundario y devuelve `self` para no cortar la cadena. No pretende reemplazar NumPy para cómputo numérico, Pandas para análisis de datos tabulares, ni `pathlib`/`csv` para manejo general de archivos, y las funciones nuevas documentan honestamente cuáles ganan, cuáles pierden, y cuáles simplemente no tienen margen real de velocidad contra Python puro — ver la sección de benchmarks para el detalle completo antes de asumir que toda función nueva acelera el caso propio. La ronda 0.4.4 documenta también cuatro funciones que se implementaron completas y se descartaron antes de llegar al release (`sort_by`, `reduce`, `take`/`drop`, `line_count`), con la evidencia medida de por qué cada una perdía contra el patrón ya existente en Python puro.

Cubierto por pruebas automatizadas (`tests/`, ejecutables con `python -m unittest discover -s tests` o con `pytest tests/`): 560 pruebas en total — funciones originales de 0.1.1 a 0.4.3 (regresión, sin una sola modificada en esta ronda), y una suite dedicada a la ronda 0.4.4 con 51 pruebas cubriendo `list_files`, `safe_set`, `safe_delete`, `deep_update`, `replace_many` y `normalize_spaces` (incluyendo el caso del bug real detectado y corregido en `safe_delete` — un nivel intermedio que existe pero no es un dict, que antes producía un `SystemError` de bajo nivel en vez de simplemente no hacer nada).



