JavaScript: la suma de porcentajes da 0.9999999999999999

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 porcentajeSuma como fracción
31001
6100.000000000000011.0000000000000002
7100.000000000000011.0000000000000002
101000.9999999999999999
11100.000000000000011.0000000000000002
131000.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

  1. 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-6 es ó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 en Number.EPSILON, así que Math.abs(suma - 1) < Number.EPSILON da false; y cien sumandos de 0.01 dan 1.0000000000000007, unas tres veces esa diferencia.

  2. 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(".", ",")} %`;
    }
  3. 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.

  4. Escribe pruebas que fallarían con ===. Estas nueve usan node: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 Number de 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.EPSILON es 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.

Referencias

Comentarios