Tienes un reparto entre varios miembros (porcentajes de participación, cuotas o pesos). La interfaz indica que está completo, pero el motor de validación lo rechaza con un mensaje como este:
Error: El reparto debe sumar 1 (suma actual: 0.9999999999999999)
La causa se reproduce con diez miembros que reciben 0.1 cada uno:
const partes = Array(10).fill(0.1);
const suma = partes.reduce((acc, p) => acc + p, 0);
console.log(suma); // 0.9999999999999999
console.log(suma === 1); // false
Este artículo explica por qué ocurre, cómo validar con una tolerancia adecuada (y por qué Number.EPSILON no basta para sumas acumuladas) y cómo repartir en unidades enteras para que el total sea exacto.
Por qué pasa
Según MDN, el tipo Number de JavaScript es un valor IEEE 754 de doble precisión y 64 bits en formato binario. Una fracción decimal como 0.1 no tiene representación binaria finita, así que se almacena el valor más cercano que cabe en 64 bits. Cada suma vuelve a redondear el resultado y esos errores diminutos se acumulan. El ejemplo clásico es este:
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
En un reparto, el resultado depende del número de miembros y de la escala. Sumando N veces el valor 100/N (en porcentaje) y N veces ese mismo valor dividido entre 100 (como fracción), Node 24 devolvió esto:
| Miembros (N) | Suma en porcentaje | Suma como fracción |
|---|---|---|
| 3 | 100 | 1 |
| 6 | 100.00000000000001 | 1.0000000000000002 |
| 7 | 100.00000000000001 | 1.0000000000000002 |
| 10 | 100 | 0.9999999999999999 |
| 11 | 100.00000000000001 | 1.0000000000000002 |
| 13 | 100 | 0.9999999999999998 |
Con 3 miembros todo parece correcto y con 11 la suma no es exactamente 1. Como el resultado cambia con N y con la escala, el fallo parece aleatorio, pero es determinista.
Redondear cada parte a dos decimales tampoco cierra el total: 3 partes de 33.33 suman 99.99 y 6 partes de 16.67 suman 100.02. Eso ya no es un problema de coma flotante sino de redondeo, y se resuelve repartiendo el resto (paso 2).
Hay un tercer culpable, que no es de precisión. Si el valor viaja entre capas como porcentaje y alguna capa lo divide entre 100 sin que otra lo sepa, dos mitades de 50 % suman 1 con una división (0.5 + 0.5) y 0.01 con dos divisiones (0.005 + 0.005). Una tolerancia no debe tapar ese error (paso 3).
Solución
-
Compara con una tolerancia en lugar de usar
===. Define una constante y úsala en toda la validación:/** Tolerancia absoluta: mucho mayor que el error de coma flotante y mucho menor que 0,01 %. */ export const TOLERANCIA = 1e-6; export interface ResultadoReparto { valido: boolean; suma: number; mensaje?: string; } /** Valida que las participaciones (fracciones entre 0 y 1) sumen 1 dentro de TOLERANCIA. */ export function validarReparto( participaciones: ReadonlyArray<number | null | undefined>, ): ResultadoReparto { const suma = participaciones.reduce<number>((acc, p) => acc + (p ?? 0), 0); if (!Number.isFinite(suma) || Math.abs(suma - 1) >= TOLERANCIA) { return { valido: false, suma, mensaje: `El reparto debe sumar 1 (suma actual: ${suma})`, }; } return { valido: true, suma }; }El valor
1e-6es órdenes de magnitud mayor que el error de coma flotante de unas pocas sumas (del orden de 1e-16) y mucho menor que la diferencia más pequeña que una persona puede introducir: 0.01 % equivale a 1e-4 como fracción. Ajústalo a la precisión real de tus datos.¿Por qué no
Number.EPSILON? MDN lo define como la diferencia entre 1 y el siguiente número representable (unos 2.22e-16), lo que lo hace útil para valores cercanos a 1 y para el error de una sola operación, y advierte que no debe usarse sin más como umbral de igualdad. En sumas acumuladas se queda corto. En la prueba, once sumandos de 1/11 se alejan de 1 exactamente enNumber.EPSILON, así queMath.abs(suma - 1) < Number.EPSILONdafalse; y cien sumandos de 0.01 dan 1.0000000000000007, unas tres veces esa diferencia. -
Reparte en unidades enteras (puntos básicos) con el método del mayor resto. Si la interfaz y el motor trabajan con enteros, la suma es exacta por construcción: MDN indica que los enteros se representan sin pérdida entre −(253 − 1) y 253 − 1. Usa 10 000 puntos básicos como 100 %:
/** * Reparte `total` unidades enteras (por defecto 10 000 puntos básicos = 100 %) * proporcionalmente a `pesos`, con el método del mayor resto. * La suma del resultado es exactamente `total`. */ export function repartirEnteros(pesos: readonly number[], total = 10_000): number[] { const sumaPesos = pesos.reduce((acc, p) => acc + p, 0); if (pesos.length === 0 || !(sumaPesos > 0)) { throw new Error("Se necesita al menos un peso positivo"); } const cuotas = pesos.map((p) => (p / sumaPesos) * total); const partes = cuotas.map((c) => Math.floor(c)); let resto = total - partes.reduce((acc, n) => acc + n, 0); const orden = cuotas .map((c, i) => ({ i, fraccion: c - Math.floor(c) })) .sort((a, b) => b.fraccion - a.fraccion || a.i - b.i); for (let k = 0; resto > 0; k = (k + 1) % orden.length) { partes[orden[k].i] += 1; resto -= 1; } return partes; }El método toma la parte entera de cada cuota y entrega las unidades sobrantes, de una en una, a quienes tienen la mayor parte decimal. El desempate por posición lo hace determinista. Con 11 miembros iguales, diez reciben 909 y uno recibe 910: suman 10 000. Convierte a porcentaje solo para mostrar:
/** Solo para mostrar: 3333 -> "33,33 %". */ export function formatearPuntosBasicos(pb: number): string { return `${(pb / 100).toFixed(2).replace(".", ",")} %`; } -
Usa una sola escala y convierte una vez. Decide si el dato viaja como fracción (0 a 1), como porcentaje (0 a 100) o como puntos básicos, y refléjalo en el nombre del campo (
participacionPb,fraccion). Convierte únicamente en los bordes: al recibir lo que escribe el usuario y al mostrarlo. Si la suma sale 0.01 o 0.0001 en lugar de 1, busca la división repetida; no aumentes la tolerancia. -
Escribe pruebas que fallarían con
===. Estas nueve usannode:test; los fixtures de los casos 2 y 3 comprueban primero que la comparación estricta realmente falla:import { test } from "node:test"; import assert from "node:assert/strict"; import { formatearPuntosBasicos, repartirEnteros, TOLERANCIA, validarReparto } from "./reparto.ts"; const suma = (xs: number[]) => xs.reduce((a, b) => a + b, 0); test("1. suma exacta es válida", () => { assert.equal(validarReparto([0.25, 0.25, 0.25, 0.25]).valido, true); }); test("2. diez veces 0.1 es válida aunque la suma no sea exactamente 1", () => { const partes = Array<number>(10).fill(0.1); assert.notEqual(suma(partes), 1); // el fixture realmente falla con === assert.equal(validarReparto(partes).valido, true); }); test("3. 1 + EPSILON es válida", () => { const partes = [0.5, 0.5 + Number.EPSILON]; assert.notEqual(suma(partes), 1); assert.equal(validarReparto(partes).valido, true); }); test("4. 50 % es inválida", () => { const r = validarReparto([0.25, 0.25]); assert.equal(r.valido, false); assert.match(r.mensaje ?? "", /debe sumar 1/); }); test("5. 150 % es inválida", () => { assert.equal(validarReparto([0.75, 0.75]).valido, false); }); test("6. 0.999 queda fuera de la tolerancia", () => { assert.ok(Math.abs(0.999 - 1) > TOLERANCIA); assert.equal(validarReparto([0.999]).valido, false); }); test("7. null y undefined cuentan como 0", () => { assert.equal(validarReparto([0.5, null, 0.5, undefined]).valido, true); }); test("8. sin datos es inválida", () => { const r = validarReparto([]); assert.equal(r.valido, false); assert.equal(r.suma, 0); }); test("9. 11 miembros suman exactamente 10 000 puntos básicos", () => { const partes = repartirEnteros(Array<number>(11).fill(1)); assert.equal(suma(partes), 10_000); assert.ok(partes.every((p) => p === 909 || p === 910)); assert.equal(formatearPuntosBasicos(partes[0]), "9,10 %"); });La prueba 6 es la más importante: una tolerancia mal elegida (por ejemplo 0.01) aceptaría sin avisar repartos que de verdad están incompletos.
Cómo verificar que quedó resuelto
node --test reparto.test.ts
npx tsc --noEmit
Node 24 ejecuta archivos .ts directamente quitando los tipos, por lo que no necesita un paso de compilación para las pruebas; el segundo comando valida los tipos. En la reproducción local las nueve pruebas pasaron, con 0 fallos, y tsc terminó con código de salida 0.
Para ganar confianza en el reparto entero, una comprobación aleatoria con unos 20 000 conjuntos de pesos (entre 1 y 40 miembros, algunos con peso 0) y repartos iguales de 1 a 300 miembros dio siempre una suma de 10 000, con enteros no negativos y cada parte a menos de una unidad de su cuota ideal.
Variantes y errores relacionados
La suma sale 0.01, 0.0001 o 100
No es coma flotante. Es una escala mal aplicada: dividir dos veces entre 100 (o ninguna) en alguna capa. Localiza la conversión duplicada en lugar de ampliar la tolerancia.
Importes de dinero
El mismo principio aplica: guarda la unidad mínima (centavos) como entero y convierte solo al mostrar.
Redondear con toFixed antes de comparar
Number(suma.toFixed(6)) === 1 funciona en la mayoría de los casos, pero pasa por una cadena y desplaza el problema al borde del redondeo. La comparación con tolerancia expresa mejor la intención.
null, undefined y NaN
En el validador, p ?? 0 trata null y undefined como 0. Un NaN contamina toda la suma, y Number.isFinite lo rechaza antes de comparar.
Cuando necesitas decimales exactos en muchos cálculos
Para repartos basta con enteros. Si tu dominio exige aritmética decimal exacta en operaciones encadenadas, evalúa una biblioteca de decimales en lugar de parchear cada comparación.
Resumen
- Los
Numberde JavaScript son binarios de doble precisión: 0.1 no es exacto y===falla al comparar sumas con 1. - Valida con
Math.abs(suma - 1) < 1e-6;Number.EPSILONes demasiado estricto para sumas acumuladas. - Elige una tolerancia mucho menor que la diferencia mínima real de tus datos, para que 0.999 siga siendo un error.
- Reparte en enteros (puntos básicos) con el método del mayor resto para que el total sea exacto.
- Define una sola escala y convierte una vez; una suma de 0.01 indica una división repetida, no un problema de precisión.
Comentarios
Publicar un comentario