Metadata-Version: 2.4
Name: formula-engine
Version: 0.1.0
Summary: Motor universal declarativo de fórmulas, expresiones y reglas (Python 3.12).
License-Expression: MIT
Project-URL: Homepage, https://github.com/your-org/formula-engine
Project-URL: Repository, https://github.com/your-org/formula-engine
Project-URL: Documentation, https://github.com/your-org/formula-engine#readme
Project-URL: Changelog, https://github.com/your-org/formula-engine/releases
Keywords: formulas,expressions,rules,engine,declarative,financial,decimal,calculation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyYAML
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: hypothesis; extra == "dev"
Requires-Dist: mutmut; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Dynamic: license-file

# formula-engine

**Simple to configure. Explicit to execute. Powerful to extend.**

[![Python](https://img.shields.io/badge/Python-%3E%3D3.12-3776AB)](https://www.python.org/)
[![tests](https://img.shields.io/badge/tests-1125%20passed-brightgreen)](docs/testing.md)
[![coverage](https://img.shields.io/badge/coverage-99%25%20line%2Fbranch-brightgreen)](docs/testing.md)
[![mutation](https://img.shields.io/badge/mutation-84.0%25-blue)](docs/mutation.md)
[![mypy](https://img.shields.io/badge/mypy-strict-blue)](pyproject.toml)
[![ruff](https://img.shields.io/badge/ruff-clean-brightgreen)](pyproject.toml)
[![version](https://img.shields.io/badge/version-0.1.0-blue)](formula_engine/__init__.py)
[![license](https://img.shields.io/badge/license-MIT-blue)](pyproject.toml)

Motor **universal declarativo** de fórmulas, expresiones y reglas para
**Python ≥ 3.12**. Configurable mediante **YAML**, **JSON** o **dict**,
agnóstico al dominio: precios, impuestos, comisiones, scoring, tarifas,
nóminas o cualquier cálculo reglamentario.

Tú defines **qué** calcular; el motor se encarga de parsear, validar, resolver
dependencias, evaluar, explicar y proteger contra ejecución arbitraria.

---

## ¿Para quién es?

- Equipos que necesitan cambiar reglas de negocio **sin desplegar código**:
  la lógica vive en un archivo YAML versionable, no en `if/else` dispersos.
- Aplicaciones financieras o reglamentarias que exigen **`Decimal` exacto**
  (nada de `0.1 + 0.2 != 0.3`), tipos estrictos y determinismo total.
- Quienes quieren **auditabilidad**: `explain()` devuelve qué se calculó,
  cómo y en qué orden.

## ¿Qué NO es?

- **No** es un sistema *no-code*: no tiene UI, editor visual ni ejecución
  remota de reglas.
- **No** es un ORM ni una capa de acceso a datos: no toca bases de datos.
- **No** trae HTTP, logging externo ni cache: el núcleo es solo el motor de
  cálculo (spec §3, §39, §44, §45). Si necesitas esas capas, van en tu
  aplicación, no en la librería.

## Características clave

- **Seguro por diseño**: sin `eval()`/`exec()`. Pipeline obligatorio
  `Lexer → Parser → AST → Validator → Evaluator`; nombres prohibidos y límites
  de complejidad → error controlado.
- **`Decimal` financiero**: aritmética exacta, sin errores de coma flotante.
- **Tipos estrictos**: 10 tipos con coerción explícita y validación fail-fast
  en carga y en cada cálculo.
- **Reglas con semántica inequívoca**: `when`/`set`/`priority`, orden por
  prioridad asc + declaración, **last-write-wins**. Sin ambigüedad de
  conflictos.
- **Dependencias automáticas**: el motor ordena fórmulas en topológico y
  detecta ciclos con el error correspondiente.
- **`explain()` estructurado**: cada cálculo devuelve el detalle de inputs,
  variables, fórmulas, reglas, outputs y orden de evaluación.
- **CLI incluida**: `validate`, `calculate`, `explain`, `dependencies`, `test`.
- **Extensible**: `register_function()` para incorporar funciones
  personalizadas sin tocar el motor.
- **Determinista e inmutable**: mismo input → mismo resultado; los datos de
  entrada nunca se mutan.

---

## Tabla de contenidos

- [Instalación](#instalación)
- [Primer cálculo en menos de 5 minutos](#primer-cálculo-en-menos-de-5-minutos)
- [Uso en 4 pasos](#uso-en-4-pasos)
- [API pública](#api-pública)
- [Ejemplos multi-dominio](#ejemplos-multi-dominio)
- [Seguridad](#seguridad)
- [CLI](#cli)
- [Documentación](#documentación)
- [Calidad y testing](#calidad-y-testing)
- [Estado y roadmap](#estado-y-roadmap)
- [Contribución](#contribución)
- [Licencia](#licencia)

---

## Instalación

Requisito: **Python >= 3.12**.

```bash
# desde la raíz del proyecto (usa tu venv si lo tienes)
pip install .

# en editable, con dependencias de desarrollo
pip install -e ".[dev]"
```

Única dependencia de runtime: **PyYAML** (para `from_yaml`). Todo lo demás es
stdlib (`decimal`, `datetime`, `json`, `argparse`). Ver `pyproject.toml`.

---

## Primer cálculo en menos de 5 minutos

Crea `pricing.yaml`:

```yaml
name: pricing

variables:
  costo:
    type: decimal
    required: true
  margen:
    type: decimal
    required: true
  cantidad:
    type: integer
    required: true

formulas:
  precio_base:
    expression: "costo * (1 + margen)"
  descuento:
    expression: "0"

rules:
  - name: volumen
    when: "cantidad >= 100"
    set:
      descuento: "precio_base * 0.10"

outputs:
  precio_final: "precio_base - descuento"
```

Calcúlalo:

```python
from formula_engine import FormulaEngine

engine = FormulaEngine.from_yaml("pricing.yaml")

resultado = engine.calculate({
    "costo": 100,
    "margen": 0.30,
    "cantidad": 150,
})
print(resultado)
```

Resultado real (los valores son `decimal.Decimal`):

```python
{'precio_final': Decimal('117.000')}
```

Qué ha pasado: la regla `volumen` aplica porque `cantidad >= 100` (150),
sobrescribe `descuento` con el 10 % de `precio_base`, y el output
`precio_final` se evalúa al final:

- `precio_base = 100 * (1 + 0.30) = 130.0`
- `descuento = 130.0 * 0.10 = 13.000`
- `precio_final = 130.0 - 13.000 = 117.000`

> **Nota**: `calculate(data)` devuelve por defecto **solo los outputs
> declarados** (`precio_final`). Para obtener también las fórmulas y los
> valores escritos por reglas usa `outputs="*"`:
>
> ```python
> engine.calculate(data, outputs="*")
> # -> {'precio_base': Decimal('130.0'),
> #     'descuento': Decimal('13.000'),
> #     'precio_final': Decimal('117.000')}
> ```

---

## Uso en 4 pasos

### 1. Definir (YAML / JSON / dict)

La definición declara variables, fórmulas, reglas y outputs (el YAML del
quickstart). Los tres formatos son **equivalentes** y semánticamente
idénticos (spec §42):

```python
engine = FormulaEngine.from_yaml("pricing.yaml")    # archivo YAML
engine = FormulaEngine.from_json("pricing.json")    # archivo JSON (mismo schema)
engine = FormulaEngine.from_dict({...})             # dict en Python
```

### 2. Cargar

`from_yaml` / `from_json` / `from_dict` **validan fail-fast**: parsean,
normalizan, validan schema, nombres, expresiones y detectan ciclos. Un error
aquí lanza una excepción de `formula_engine.errors` inmediatamente.

```python
engine = FormulaEngine.from_yaml("pricing.yaml")    # valida y construye
engine.validate()                                   # revalida (idempotente)
```

### 3. Calcular

```python
resultado = engine.calculate({"costo": 100, "margen": 0.30, "cantidad": 150})
# {'precio_final': Decimal('117.000')}
```

El pipeline es `inputs → fórmulas → reglas → outputs`. Los datos de entrada
**no se mutan** y las claves extra se ignoran. `outputs` admite `None` (solo
outputs declarados), una lista de nombres computados en el orden pedido, o
`"*"` (fórmulas + targets de reglas + outputs).

### 4. Explicar

```python
reporte = engine.explain({"costo": 100, "margen": 0.30, "cantidad": 150})
```

`explain()` reutiliza el mismo pipeline (no recalcula: captura los valores
intermedios reales) y devuelve un dict estructurado:

```python
{
    "definition": {"name": "pricing", "version": None},
    "inputs": {"costo": 100, "margen": 0.3, "cantidad": 150},
    "variables": {"costo": {"value": "100", "source": "input"}},
    "formulas": {
        "precio_base": {"expression": "costo * (1 + margen)",
                        "value": "130.0", "dependencies": ["costo", "margen"]},
    },
    "rules": [{"name": "volumen",
               "when": {"expression": "cantidad >= 100", "result": True},
               "set": {"descuento": {"expression": "precio_base * 0.10",
                                     "value": "13.000"}},
               "applied": True}],
    "outputs": {"precio_final": {"expression": "precio_base - descuento",
                                 "value": "117.000"}},
    "order": ["cantidad", "margen", "costo", "precio_base",
              "descuento", "volumen", "precio_final"],
}
```

---

## API pública

Public API **estable** (spec §5, §41), expuesta desde `formula_engine`:

```python
from formula_engine import FormulaEngine

engine = FormulaEngine.from_yaml("formula.yaml")   # o from_json / from_dict
engine.validate()                                  # revalida (idempotente)
engine.calculate(data, outputs=None)               # resultado: dict con Decimal
engine.explain(data)                               # dict estructurado del cálculo
engine.dependencies()                              # grafo de dependencias
engine.register_function("mi_fn", fn)              # extiende el registry
```

- `from_yaml(path)` / `from_json(path)` / `from_dict(data)` — cargan y validan
  la definición *fail-fast*. El chequeo de funciones aún no registradas se
  difiere a `validate()`/`calculate()` (ver [funciones](docs/functions.md)).
- `validate()` — re-ejecuta la validación completa contra el registry actual.
- `calculate(data, outputs=None)` — evalúa el pipeline
  `inputs → fórmulas → reglas → outputs`. `outputs=None` devuelve solo los
  outputs declarados; una lista devuelve cualquier nombre computado (fórmulas
  u outputs) en el orden pedido; `"*"` devuelve fórmulas + targets de reglas +
  outputs. `data` nunca se muta y las claves extra se ignoran.
- `explain(data)` — devuelve el dict anidado del paso 4 (entradas, variables
  con su origen, fórmulas, reglas, outputs y orden de evaluación).
- `dependencies()` — `{"variables": [...], "formulas": {...}, "rules": {...},
  "outputs": {...}, "order": [...]}`.
- `register_function(name, fn)` — registra una función personalizada; los
  nombres prohibidos (`eval`, `exec`, dunders, …), los ya registrados y los
  que colisionan con definiciones existentes se rechazan con
  `DefinitionError`.

**Errores**: las 10 excepciones públicas heredan de `FormulaEngineError` y
llevan contexto estructurado (expresión, variable, fórmula, regla, causa).
Ver [errors](docs/errors.md).

```python
from formula_engine import (
    FormulaEngineError, DefinitionError, ParseError, ValidationError,
    TypeValidationError, UnknownVariableError, UnknownFunctionError,
    CircularDependencyError, EvaluationError, UnsafeExpressionError,
)
```

---

## Ejemplos multi-dominio

Todos los ejemplos están en `examples/`, organizados por dominio (una carpeta
autocontenida por ejemplo) y verificados con casos de prueba integrados.
Ejecútalos todos con `python examples/demo.py` o uno a uno con
`formula-engine test examples/<dominio>/<dominio>.yaml`:

| Ejemplo | Dominio | Demuestra |
|---|---|---|
| `examples/pricing/pricing.yaml` | Precios | fórmula + regla de volumen + output |
| `examples/taxes/taxes.yaml` | Impuestos (IVA + retención) | reglas con `priority` sobre el mismo target, `string` |
| `examples/commissions/commissions.yaml` | Comisiones por tramos | `when` compuesto con `and`, regla sin condición, `min`/`max` |
| `examples/credit_scoring/credit_scoring.yaml` | Scoring de crédito | `or`/`!=`, reglas que escriben strings, `min(puntos, 100)` |
| `examples/payroll/payroll.yaml` | Nómina | `Decimal`, `round`, defaults, regla que suma al output |
| `examples/shipping/shipping.yaml` | Envíos y tarifas | 5 reglas → 3 targets, booleano `urgente` |
| `examples/savings/savings.yaml` | Interés compuesto | `pow()` Decimal, múltiples outputs |

```bash
python examples/demo.py                                          # ejecuta los 7 ejemplos
formula-engine test examples/taxes/taxes.yaml                    # 3/3 passed
formula-engine calculate examples/payroll/payroll.yaml examples/payroll/payroll_input.json
```

> **Nota**: las definiciones de `examples/` incluyen una sección `tests` de
> **nivel CLI** (spec §32) que el schema del motor rechaza con
> `DefinitionError` — es la CLI (y `examples/demo.py`) quien la separa antes
> de construir el motor. El quickstart de este README usa la definición
> mínima sin esa sección.

---

## Seguridad

**Sin `eval()` ni `exec()`.** Toda expresión pasa por el pipeline obligatorio

```text
Lexer → Parser → AST → Validator → Evaluator
```

El AST solo contiene las operaciones de la gramática; el evaluador resuelve
llamadas únicamente contra el registry de funciones registradas, nunca contra
`globals()`/`getattr`. Límites aplicados en validación:

| Límite | Valor | Error |
|---|---|---|
| Longitud de expresión | 10 000 caracteres | `UnsafeExpressionError` |
| Profundidad de AST | 300 | `UnsafeExpressionError` |
| Nombres prohibidos | `eval`, `exec`, `open`, `os`, `__import__`, dunders (`__class__`…) | `UnsafeExpressionError` |

```python
FormulaEngine.from_dict({
    "name": "x",
    "variables": {},
    "formulas": {"f": {"expression": "eval(\"1\")"}},
    "outputs": {"o": "f"},
})
# UnsafeExpressionError: forbidden name 'eval'
```

Ver [security](docs/security.md) para la lista completa de vectores probados
y [extending](docs/extending.md) para cómo registrar funciones seguras.

---

## CLI

`formula-engine` se instala como comando del paquete con 5 subcomandos
(spec §32):

```bash
formula-engine validate examples/pricing/pricing.yaml
formula-engine calculate examples/pricing/pricing.yaml examples/pricing/input.json
formula-engine explain examples/pricing/pricing.yaml examples/pricing/input.json
formula-engine dependencies examples/pricing/pricing.yaml
formula-engine test examples/pricing/pricing.yaml
```

- `validate` — valida la definición y construye el motor; imprime
  `OK: pricing v1.0.0 valid` (exit 0) o un error claro en stderr (exit 1).
- `calculate` / `explain` — necesitan un JSON con los datos de entrada
  (objeto plano); la salida es JSON. Los `Decimal` se serializan como número;
  las fechas como ISO-8601; un `Decimal` fuera de rango de `float` se
  serializa como string para que la serialización nunca falle.
- `dependencies` — imprime el grafo de dependencias como JSON.
- `test` — ejecuta la sección `tests` de la definición
  (`[{name?, input, expect}]`, comparación Decimal-aware); FAIL → exit 1.

Ejemplo real:

```bash
$ formula-engine validate examples/pricing/pricing.yaml
OK: pricing v1.0.0 valid

$ formula-engine test examples/taxes/taxes.yaml
OK gran empresa retiene 20 %
OK pyme retiene 10 %
OK cliente sin convenio usa el default 15 %
3/3 passed

$ formula-engine calculate examples/payroll/payroll.yaml examples/payroll/payroll_input.json
{
  "bruto": 2625.0,
  "deduccion_irpf": 393.75,
  "deduccion_ss": 157.5,
  "neto": 2073.75
}
```

Exit codes: `0` éxito; `1` error de definición, de entrada, de archivo o del
motor; `2` error de uso de argparse.

---

## Documentación

Guías completas en `docs/`:

- [getting-started](docs/getting-started.md) — instalación, primer cálculo, CLI
- [definitions](docs/definitions.md) — formato de definición y schema
- [expressions](docs/expressions.md) — lenguaje de expresiones
- [variables](docs/variables.md) — variables y tipos
- [formulas](docs/formulas.md) — fórmulas y dependencias
- [rules](docs/rules.md) — reglas y conflictos
- [functions](docs/functions.md) — funciones built-in y personalizadas
- [types](docs/types.md) — sistema de tipos y `Decimal`
- [errors](docs/errors.md) — excepciones y contexto
- [security](docs/security.md) — seguridad y vectores de ataque
- [testing](docs/testing.md) — suite de tests y cobertura
- [architecture](docs/architecture.md) — arquitectura interna
- [extending](docs/extending.md) — cómo extender el motor
- [performance](docs/performance.md) — benchmarks
- [mutation](docs/mutation.md) — mutation testing

---

## Calidad y testing

Estado real del repositorio (verificado en la entrega de la versión 0.1.0):

| Métrica | Valor |
|---|---|
| Tests | **1125 passed** (`pytest -q`) |
| Cobertura de línea | **99 %** (1441 líneas, 4 sin cubrir) |
| Cobertura de branch | **99 %** (588 branches, 12 partial) |
| Mutation score | **84.0 %** (2260/2689 mutantes matados) |
| Lint | `ruff check .` — limpio |
| Tipos | `mypy .` — estricto, limpio (51 archivos) |

Comandos para reproducir:

```bash
pytest -q                                             # 1125 passed
pytest --cov=formula_engine --cov-branch --cov-report=term   # cobertura
ruff check .                                          # limpio
mypy .                                                # estricto, limpio
mutmut run --use-coverage                             # mutation score (lento)
```

Ver [testing](docs/testing.md) y [mutation](docs/mutation.md).

---

## Estado y roadmap

- **Versión**: `0.1.0` (`formula_engine.__version__`), versionado semántico.
  La Public API documentada arriba es **estable**; los módulos internos
  (`parser/`, `ast/`, `evaluator/`, …) son Internal API y pueden cambiar sin
  aviso (spec §41).
- **Estado**: Beta — núcleo completo y verificado: parser, AST, evaluador,
  tipos, variables, fórmulas, dependency resolver, reglas, funciones, explain,
  seguridad, CLI y packaging.

**Ideas futuras (roadmap honesto, sin fecha)**:

- Cache de AST por definición (reutilizar árbol parseado entre cálculos).
- Más built-ins (potencias modulares, funciones trigonométricas, agregados).
- Tipos dedicados `money` y `percentage` con reglas de redondeo configurables.
- Plugins de funciones cargados desde YAML.

Nada de esto existe aún; si lo necesitas, `register_function()` cubre los
casos de funciones hoy.

---

## Contribución

1. Clona el repositorio y crea un venv con **Python >= 3.12**:
   ```bash
   git clone <url-del-repo> && cd formula-engine
   python -m venv .venv
   .venv/bin/pip install -e ".[dev]"
   ```
2. Antes de enviar un PR, todo debe pasar:
   ```bash
   pytest
   ruff check .
   mypy .
   ```
3. **Política de PRs**:
   - Tests obligatorios: cada bug corregido lleva su test de regresión
     (spec §29); cada feature nueva lleva tests de comportamiento real.
   - No romper cobertura ≥ 90 %; los módulos críticos deben mantenerla aún
     más alta.
   - Prohibido `eval()`/`exec()` en el código del motor (spec §8).
   - La lógica de dominio no se mezcla con serialización; la CLI no contiene
     lógica de motor (spec §35).
   - Documentación: si cambia la API pública o el schema, actualiza `docs/`
     y el README en el mismo PR.

Guías de referencia: [architecture](docs/architecture.md),
[extending](docs/extending.md) y [testing](docs/testing.md).

---

## Publicación (PyPI)

El paquete se construye con `python -m build` y se verifica con `twine check`
(ambos en `.[dev]`). El CI (`.github/workflows/ci.yml`) valida calidad en cada
push/PR; `.github/workflows/publish.yml` publica automáticamente en
PyPI/TestPyPI al crear un tag `v*` o vía `workflow_dispatch` (Trusted
Publishing, sin tokens). Procedimiento completo: [RELEASE.md](RELEASE.md).

```bash
pip install -e ".[dev]"
python -m build          # genera dist/*.whl + dist/*.tar.gz
twine check dist/*       # valida metadatos
```

---

## Licencia

**MIT** — declarada en `pyproject.toml` y `LICENSE`. Uso libre, incluido uso
comercial, con atribución y sin garantía.
