# HugeNat

`HugeNat` es un wrapper ligero sobre `int` que mantiene la semántica de los enteros de Python para números naturales (ℕ₀) y añade indexado/slicing por bits, vistas NumPy y un núcleo listo para Numba.

Repositorio: https://github.com/nand0san/huge_nat

## Instalación

```bash
pip install HugeNats
```

## Creación rápida

```python
import numpy as np
from hugenat import HugeNat

# Desde un entero no negativo
x = HugeNat(123456789)

# Desde un float exactamente entero (sin parte decimal, <= 2**53)
f = HugeNat(3.0)          # → HugeNat(3)
# HugeNat(3.5)            → ValueError: El float debe ser un entero exacto
# HugeNat(float(2**54))   → ValueError: demasiado grande para representación exacta

# Desde limbs (uint64, little-endian: limb 0 es LSB)
limbs = np.array([0xFFFFFFFFFFFFFFFF, 0x1], dtype=np.uint64)
y = HugeNat(limbs)

# Desde una lista/tupla de enteros (se convierten a uint64 y se recortan ceros finales)
z = HugeNat([1, 2, 3])

# Con anchura lógica fija: conserva ceros a la izquierda o trunca por arriba
padded = HugeNat(0b1011, bit_length=8)     # 00001011 (ancho lógico 8)
cut = HugeNat(0b110101, bit_length=4)      # 0101 (se recorta a 4 bits)
```

## API tipo int

- `int(x)`, `float(x)`, `bool(x)`, `hash(x)`, `str(x)` reflejan al entero interno.
- Métodos compatibles: `bit_length()`, `bit_count()`, `to_bytes()`, `from_bytes()`.
- Aritmética y bitwise aceptan solo naturales (`HugeNat` o `int >= 0`) y devuelven siempre `HugeNat`.
- Las restas que producirían un valor negativo lanzan `ValueError`.
- `-x` lanza `ValueError` (los naturales no tienen negación).
- Si se declara `bit_length` al construir, `bit_length()` devuelve esa anchura lógica fijada.
- Las operaciones bitwise (`&`, `|`, `^`, `<<`, `>>`) **preservan** `_fixed_nbits` automáticamente cuando `self` tiene ancho fijo. Si ambos operandos son `HugeNat` con el mismo ancho fijo, el resultado lo hereda; si solo `self` lo tiene, también; si ambos difieren, el resultado no tiene ancho fijo. Los shifts aplican máscara al ancho fijo.
- Operadores unarios: `abs(x)` y `+x` devuelven una copia (identidad para naturales, preservan `bit_length` fijo).
- `divmod(a, b)` devuelve `(HugeNat, HugeNat)`.
- Todos los operadores reflejados están implementados, incluyendo `**` (`2 ** HugeNat(3)` -> `HugeNat(8)`).
- `bool` no se acepta como shift en `<<`, `>>`, `rotl` ni `rotr`; usa `int` explícito.

```python
a = HugeNat(10)
b = HugeNat(7)

int(a + b)        # 17
int(a * b)        # 70
int(a // b)       # 1
int(a % b)        # 3
int(a << 3)       # 80
int(a | b), int(a & b), int(a ^ b)
float(a)          # 10.0

divmod(a, b)      # (HugeNat(1), HugeNat(3))
2 ** HugeNat(3)   # HugeNat(8)
abs(a)            # HugeNat(10)
-a                # ValueError: HugeNat no permite valores negativos
```

## Indexado de bits

- Convención: `LSB = índice 0`. Índices negativos son relativos a `bit_length()`.
- Fuera de rango devuelve `0`.

```python
x = HugeNat(0b1101101)  # 109

x[0]    # 1 (LSB)
x[-1]   # 1 (MSB)
x[100]  # 0
```

## Slicing de bits

- `step` en `{None, 1}` usa ruta rápida: normaliza como Python y rellena con ceros si el límite superior excede `bit_length()`.
- Cualquier otro `step` (salvo 0) usa ruta general con semántica completa de slicing de listas y reempaquetado LSB-first.
- Con `step < 0`, si el índice inicial cae por encima de `bit_length()`, esos bits se consideran ceros implícitos.
- `step == 0` -> `ValueError`.

```python
x = HugeNat(0b1101101)

x[0:3]        # bits 0..2 -> 0b101 (5)
x[2:5]        # 0b110 (6)
x[0:7:2]      # toma cada 2 bits -> 0b1011 (11)
x[5:0:-2]     # slicing con paso negativo
x[3:10]       # incluye ceros implícitos por encima de bit_length()
```

## Anchura lógica fija (`bit_length` al construir)

Por defecto, `HugeNat` se comporta como antes: la anchura es `int(x).bit_length()`.

Si pasas `bit_length=...`, fijas la anchura lógica del objeto:

- si el valor tiene más bits, se trunca por arriba;
- si tiene menos, se consideran ceros a la izquierda;
- `bit_length()` devuelve siempre la anchura fijada.

```python
x = HugeNat(0b1011, bit_length=8)

int(x)         # 11
x.bit_length() # 8
x[-1]          # 0  (bit más alto lógico)
x[-8]          # 1
```

## División en trozos (`split(n)`)

`split(n)` divide el número en `n` trozos de igual cantidad de bits.

- `n` debe ser `int` y `n >= 1`.
- `bit_length()` debe ser divisible por `n`, si no lanza `ValueError`.
- Devuelve una tupla de `HugeNat` en orden `LSB -> MSB` (trozo de menor peso primero).
- Cada trozo conserva su anchura lógica fija (`bit_length` del trozo).
- Si `bit_length() == 0`, devuelve `n` trozos `HugeNat(0, bit_length=0)`.

```python
x = HugeNat(0b1011010110010001, bit_length=16)
parts = x.split(4)

[int(p) for p in parts]       # [0b0001, 0b1001, 0b0101, 0b1011] -> [1, 9, 5, 11]
[p.bit_length() for p in parts]  # [4, 4, 4, 4]
```

## Concatenación de bits (`append`)

`append(B, C, ...)` concatena los bits de uno o más `HugeNat` sobre los de `self`.

- Los bits de `self` quedan en las posiciones bajas (LSB), los de `B` justo encima, los de `C` encima de `B`, etc.
- Se respeta el `bit_length()` de cada operando, incluidos los ceros altos declarados.
- El resultado tiene `bit_length` = suma de los anchos de todos los operandos.
- Es la operación inversa de `split`: `parts[0].append(*parts[1:])` reconstruye el valor original.

```python
a = HugeNat(0b11, bit_length=2)     # 11
b = HugeNat(0b01, bit_length=2)     # 01
r = a.append(b)
int(r), r.bit_length()              # (7, 4)  ->  bits: 01|11

# Con ceros altos preservados
a = HugeNat(0b1, bit_length=4)      # 0001
b = HugeNat(0b1, bit_length=4)      # 0001
r = a.append(b)
int(r), r.bit_length()              # (17, 8) ->  bits: 0001|0001

# Múltiples operandos
a = HugeNat(0b10, bit_length=2)
b = HugeNat(0b01, bit_length=2)
c = HugeNat(0b11, bit_length=2)
r = a.append(b, c)
int(r), r.bit_length()              # (54, 6) ->  bits: 11|01|10

# Inversa de split
x = HugeNat(0xDEADBEEFCAFEBABE, bit_length=64)
parts = x.split(4)
reconstructed = parts[0].append(*parts[1:])
assert int(reconstructed) == int(x)
```

## Array de bits

`bits(order="msb->lsb" | "lsb->msb", length=None)` devuelve `np.ndarray` de `uint8`.

```python
x = HugeNat(0b1011)

np.asarray(x.bits())                  # array([1, 0, 1, 1], dtype=uint8)
np.asarray(x.bits(order="lsb->msb")) # array([1, 1, 0, 1], dtype=uint8)
np.asarray(x.bits(length=8))          # padding a la izquierda: 00001011
```

## Cadena de bits agrupados

`bits_str(order="msb->lsb" | "lsb->msb", group=64, sep=" ")` para depurar o mostrar.

```python
x = HugeNat(0x0123456789ABCDEFFEDCBA9876543210)

x.bits_str(group=4)          # grupos de 4 bits
x.bits_str(group=8)          # grupos de 1 byte
x.bits_str(order="lsb->msb", group=8)
```

## Bytes ida y vuelta

```python
x = HugeNat(2**20 + 123)
length = (x.bit_length() + 7) // 8

b = x.to_bytes(length=length, byteorder="big", signed=False)
y = HugeNat.from_bytes(b, byteorder="big", signed=False)

assert int(y) == int(x)
```

## Rotaciones de bits

Las rotaciones usan el ancho lógico (`bit_length()`):

```python
x = HugeNat(0b100101)

int(x.rotl(2))  # 0b010110
int(x.rotr(2))  # 0b011001

HugeNat(0).rotl(5)  # -> HugeNat(0)
```

## NOT bit a bit (`~`)

`~x` voltea todos los bits dentro del ancho lógico del valor y devuelve un `HugeNat` con ese mismo ancho fijado.

- Con `bit_length` declarado: voltea dentro de esa anchura.
- Sin `bit_length` declarado: usa el `bit_length()` natural (número de bits significativos).
- `HugeNat(0)` sin anchura declarada tiene `bit_length() = 0`; no hay bits que voltear, devuelve `HugeNat(0, bit_length=0)`.
- `~~x == x` siempre.

```python
~HugeNat(0b1011, bit_length=4)   # → HugeNat(4, bit_length=4)  → 0b0100
~HugeNat(0b1011)                  # → HugeNat(4, bit_length=4)  (bit_length natural = 4)
~HugeNat(0, bit_length=8)         # → HugeNat(255, bit_length=8) → 0xFF
~HugeNat(0xFF, bit_length=8)      # → HugeNat(0, bit_length=8)

# Uso típico: máscara de bits
x = HugeNat(0b10110010, bit_length=8)
mask = ~x                         # → 0b01001101, bit_length=8
```

## Núcleo Numba-friendly

`to_core(copy=True, word_order="le")` devuelve `(limbs, nbits)` con `limbs: uint64[::1]` (1D contiguo) y `nbits: int`.

- `copy=True` (default): devuelve una copia independiente.
- `copy=False`: devuelve una vista read-only de la cache interna (sin allocar).
- `word_order="le"` (default): limbs en little-endian (`limb 0` = bits 0..63).
- `word_order="be"`: limbs en big-endian (MSB primero).

Los limbs se cachean internamente (HugeNat es inmutable), por lo que llamadas repetidas con `copy=False` son O(1).

`from_core(limbs, nbits, word_order="le")` reconstruye el valor y fija la anchura lógica a `nbits`. Con `word_order="be"`, invierte los limbs antes de procesarlos.

Ejemplo Numba (histograma/unique de nibbles de 4 bits, empezando en el LSB) con signatura estricta y retorno tipado. Observa que `seen:uint8` y `counts:uint32` requieren `types.Tuple`, no `UniTuple`.

```python
import numpy as np
from numba import njit, types

x = HugeNat(2**127 + 0xF00D)
limbs, nbits = x.to_core()

# Asegurar contigüidad y tipos exactos para la signatura
limbs = np.ascontiguousarray(limbs, dtype=np.uint64)
nbits = np.int64(nbits)

RET = types.Tuple((types.Array(types.uint8, 1, "C"), types.Array(types.uint32, 1, "C")))
SIG = RET(types.Array(types.uint64, 1, "C"), types.int64)

@njit(SIG, cache=False)
def unique_nibbles_core(limbs, nbits):
    counts = np.zeros(16, dtype=np.uint32)
    seen = np.zeros(16, dtype=np.uint8)

    if nbits <= 0 or limbs.size == 0:
        return seen, counts

    n_nibbles = nbits >> 2  # solo nibbles completos

    for k in range(n_nibbles):
        bitpos = k << 2       # desplaza 4 bits desde el LSB
        limb_i = bitpos >> 6
        off = bitpos & 63

        x0 = limbs[limb_i]
        if off <= 60:
            nib = (x0 >> np.uint64(off)) & np.uint64(0xF)
        else:
            lo = x0 >> np.uint64(off)
            hi = np.uint64(0)
            if limb_i + 1 < limbs.size:
                hi = limbs[limb_i + 1] << np.uint64(64 - off)
            nib = (lo | hi) & np.uint64(0xF)

        idx = int(nib)
        counts[idx] += np.uint32(1)
        seen[idx] = np.uint8(1)

    return seen, counts

seen, counts = unique_nibbles_core(limbs, nbits)
unique_values = np.nonzero(seen)[0].astype(np.uint8)

# Vuelta al wrapper para seguir trabajando en Python
y = HugeNat.from_core(limbs, nbits)
assert int(y) == int(x)

unique_values, counts[unique_values]
```

## Extracción directa a `uint64[]` (sin objetos intermedios)

Estas APIs evitan crear objetos `HugeNat` por trozo, devolviendo arrays NumPy directamente desde la cache de limbs.

### `split_core(parts, word_order="le")`

Divide en `parts` trozos iguales y devuelve un array 2D `(parts, words_per_part)` de `uint64`. Fila 0 = chunk LSB.

```python
x = HugeNat(0xDEADBEEFCAFEBABE, bit_length=64)
arr = x.split_core(4)      # shape (4, 1), dtype uint64
arr_be = x.split_core(4, word_order="be")
```

### `split_u64(parts)`

Variante para chunks de hasta 64 bits. Devuelve array 1D `(parts,)` de `uint64`.

```python
x = HugeNat(0xDEADBEEFCAFEBABE, bit_length=64)
chunks = x.split_u64(4)    # array([0xBABE, 0xCAFE, 0xBEEF, 0xDEAD], dtype=uint64)
```

### `extract_bits_core(start, width, word_order="le")`

Extrae `width` bits desde la posición `start` como array 1D de `uint64`.

```python
x = HugeNat(2**200 + 123456789)
low_128 = x.extract_bits_core(0, 128)           # 2 limbs
mid_64  = x.extract_bits_core(64, 64)           # 1 limb
be_view = x.extract_bits_core(0, 128, word_order="be")
```

### `extract_bits_u64(start, width)`

Variante para `width <= 64`. Devuelve un solo `np.uint64`.

```python
x = HugeNat(0xDEADBEEFCAFEBABE, bit_length=64)
x.extract_bits_u64(0, 16)   # np.uint64(0xBABE)
x.extract_bits_u64(48, 16)  # np.uint64(0xDEAD)
```

## Kernels Numba (`hugenat.numba_core`)

Funciones `@njit` reutilizables que operan directamente sobre arrays `(limbs, nbits)`:

```python
from hugenat.numba_core import split_equal_parts_core, extract_range_core, concat_parts_core

x = HugeNat(0xDEADBEEFCAFEBABE, bit_length=64)
limbs, nbits = x.to_core()

# Dividir en 4 trozos de 16 bits → array 2D (4, 1)
parts_2d = split_equal_parts_core(limbs, int(nbits), 4)

# Extraer 32 bits desde posición 16 → (limbs_out, nbits_out)
out_limbs, out_nbits = extract_range_core(limbs, int(nbits), 16, 32)

# Reconstruir desde partes 2D
recon_limbs, recon_nbits = concat_parts_core(parts_2d, 16)
y = HugeNat.from_core(recon_limbs, recon_nbits)
assert int(y) == int(x)
```

- `split_equal_parts_core(limbs, nbits, parts)` → array 2D `(parts, words_per_part)`.
- `extract_range_core(limbs, nbits, start, width)` → `(limbs_out, nbits_out)`.
- `concat_parts_core(parts_2d, chunk_bits)` → `(limbs_out, nbits_out)`.

## Contrato de dominio

- Solo enteros `>= 0` o arrays 1D de limbs `uint64` (little-endian). Valores negativos o dimensiones distintas lanzan `ValueError`.
- Sin `bit_length` explícito, los ceros de mayor peso se recortan automáticamente.
- Con `bit_length` explícito, la anchura lógica se conserva aunque haya ceros a la izquierda.
- Las restas que producirían negativos lanzan `ValueError`.

## Desarrollo

- Dependencias: `numpy`, `numba`.
- Dependencias de desarrollo: `pytest`.
- Ejecuta la batería completa: `pytest -q`.

Las demostraciones completas viven en `HugeNat_demo.ipynb` y cubren todos los ejemplos anteriores.
