21. Precisión numérica, tolerancias y casos límite

Las computadoras aproximan muchos números reales. Comprender ese límite permite comparar vectores correctamente, evitar resultados indefinidos y construir simulaciones estables.

21.1 El cálculo exacto y el cálculo digital

En matemática, 0,1 + 0,2 = 0,3. En JavaScript, los números se almacenan normalmente con el formato binario IEEE 754 de doble precisión. Muchos decimales finitos no tienen una representación binaria finita y deben redondearse.

const suma = 0.1 + 0.2;
console.log(suma);
console.log(suma === 0.3);
console.log(suma - 0.3);

El resultado no implica que JavaScript calcule al azar. Es la aproximación binaria más cercana dentro de la precisión disponible. El error suele ser diminuto, pero una comparación exacta puede hacerlo visible.

21.2 Cómo se representa un número

Un número de coma flotante combina signo, significando y exponente. Esto permite representar valores extremadamente grandes y pequeños, pero la separación entre números representables aumenta con su magnitud.

Number.EPSILON es la distancia entre 1 y el siguiente número representable mayor que 1. No es una tolerancia universal.

console.log("Number.EPSILON:", Number.EPSILON);
console.log("1 + EPSILON:", 1 + Number.EPSILON);
console.log("1e16 + 1:", 1e16 + 1);
console.log("¿Se conservó la unidad?", 1e16 + 1 !== 1e16);

21.3 Igualdad aproximada absoluta

Dos valores son aproximadamente iguales si su diferencia absoluta no supera una tolerancia acordada:

|a − b| ≤ ε
function casiIgualesAbs(a, b, epsilon = 1e-10) {
  return Math.abs(a - b) <= epsilon;
}

const resultado = 0.1 + 0.2;
console.log("Igualdad exacta:", resultado === 0.3);
console.log("Igualdad aproximada:", casiIgualesAbs(resultado, 0.3));

La tolerancia absoluta funciona bien cerca de cero y cuando conocemos la escala física del problema. Por ejemplo, en una interfaz gráfica puede ser razonable considerar iguales posiciones separadas por menos de una milésima de píxel.

21.4 Tolerancia relativa

Una tolerancia absoluta fija puede ser demasiado estricta para valores grandes y demasiado permisiva para valores pequeños. La tolerancia relativa escala con la magnitud:

|a − b| ≤ εrel · max(|a|, |b|)
function casiIgualesRel(a, b, rel = 1e-9) {
  const escala = Math.max(Math.abs(a), Math.abs(b));
  return Math.abs(a - b) <= rel * escala;
}

console.log(casiIgualesRel(1_000_000_000, 1_000_000_000.5));
console.log(casiIgualesRel(0, 1e-12)); // cerca de cero falla

21.5 Combinar tolerancias absoluta y relativa

Una comparación robusta utiliza un umbral absoluto cerca de cero y uno relativo para magnitudes grandes:

function casiIguales(a, b, absTol = 1e-12, relTol = 1e-9) {
  if (a === b) return true;
  if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
  const diferencia = Math.abs(a - b);
  const escala = Math.max(Math.abs(a), Math.abs(b));
  return diferencia <= Math.max(absTol, relTol * escala);
}

console.log(casiIguales(0.1 + 0.2, 0.3));
console.log(casiIguales(0, 5e-13));
console.log(casiIguales(Infinity, Infinity));

La comprobación exacta inicial hace que dos infinitos del mismo signo sean iguales. Los demás valores no finitos se rechazan antes de restarlos.

21.6 Elegir una tolerancia con significado

La tolerancia depende del dominio, las unidades y el propósito. No se elige solamente mirando Number.EPSILON.

Ejemplos conceptuales; cada aplicación debe justificar sus umbrales.
SituaciónEscala relevanteCriterio posible
Coordenadas en pantallaPíxelesUna fracción de píxel
Robot móvilMetros y precisión del sensorMayor que el ruido medido
Vector unitarioMódulo cercano a 1Error adimensional pequeño
Tiempo de simulaciónSegundos y paso temporalFracción del paso dt
Regla práctica: la tolerancia debe expresar cuánto error puede aceptar el problema, no cuántos decimales queremos ver.

21.7 Comparar vectores

Podemos comparar componentes o medir la distancia entre extremos. Ambos criterios responden preguntas ligeramente diferentes.

const distancia = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);

function vectoresCercanos(a, b, tolerancia = 1e-9) {
  return distancia(a, b) <= tolerancia;
}

const a = { x: 0.1 + 0.2, y: Math.sqrt(4) };
const b = { x: 0.3, y: 2 };
console.log("Exactamente iguales:", a.x === b.x && a.y === b.y);
console.log("Geométricamente cercanos:", vectoresCercanos(a, b));

La distancia euclídea produce un umbral circular. Comparar cada componente produce una región cuadrada. La geometría del problema indica cuál conviene.

21.8 ¿Cuándo un vector es nulo?

Después de varias operaciones, un vector que matemáticamente debería ser nulo puede conservar residuos diminutos. En vez de comprobar x === 0 && y === 0, comparamos su módulo con un umbral.

const modulo2 = v => v.x * v.x + v.y * v.y;

function esCasiNulo(v, tolerancia = 1e-9) {
  return modulo2(v) <= tolerancia * tolerancia;
}

console.log(esCasiNulo({ x: 1e-12, y: -2e-12 }));
console.log(esCasiNulo({ x: 1e-4, y: 0 }));

Comparar módulos al cuadrado evita calcular una raíz cuadrada. Es imprescindible elevar también la tolerancia al cuadrado.

21.9 Normalización segura

Dividir por un módulo extremadamente pequeño amplifica ruido numérico. Una normalización segura acepta un umbral relacionado con la escala de la aplicación.

function normalizarSeguro(v, tolerancia = 1e-12) {
  const modulo = Math.hypot(v.x, v.y);
  if (!Number.isFinite(modulo) || modulo <= tolerancia) return null;
  return { x: v.x / modulo, y: v.y / modulo };
}

console.log(normalizarSeguro({ x: 3, y: 4 }));
console.log(normalizarSeguro({ x: 1e-15, y: 0 }));

21.10 Acumulación de errores

Un error pequeño puede acumularse en un bucle largo. Sumar 0.1 diez veces no necesariamente llega exactamente a 1.

let tiempo = 0;
for (let i = 0; i < 10; i++) tiempo += 0.1;

console.log("Tiempo acumulado:", tiempo);
console.log("¿Es exactamente 1?", tiempo === 1);
console.log("Error:", tiempo - 1);

Cuando sea posible, conviene calcular tiempo = paso * cantidadDePasos o mantener un contador entero. En una simulación, el estado igualmente acumula aproximaciones, por lo que hay que observar invariantes como energía, longitud o velocidad.

21.11 Cancelación y orden de las operaciones

Restar números grandes muy cercanos puede eliminar dígitos significativos. También el orden de una suma cambia cuánto se pierde.

const grande = 1e16;
console.log((grande + 1) - grande);
console.log((grande - grande) + 1);

const posicionGlobal = 1e12 + 0.001;
console.log("Detalle recuperado:", posicionGlobal - 1e12);

En mundos virtuales extensos se usa a veces un origen local cercano al observador. Así las posiciones que se restan tienen escalas moderadas y conservan más detalle.

21.12 Suma compensada

El algoritmo de Kahan conserva una compensación para parte de los bits perdidos al sumar muchos valores. No vuelve exacta toda operación, pero puede reducir el error acumulado.

function sumaKahan(valores) {
  let suma = 0, compensacion = 0;
  for (const valor of valores) {
    const y = valor - compensacion;
    const t = suma + y;
    compensacion = (t - suma) - y;
    suma = t;
  }
  return suma;
}

const datos = [1e16, 1, 1, 1, 1, -1e16];
console.log("Suma común:", datos.reduce((a, b) => a + b, 0));
console.log("Suma compensada:", sumaKahan(datos));

21.13 NaN, Infinity y valores finitos

NaN representa un resultado numérico inválido; Infinity puede aparecer por desbordamiento o división por cero. Ambos se propagan y pueden contaminar toda una simulación.

const valores = [3, NaN, Infinity, -Infinity, "3"];
for (const valor of valores) {
  console.log(String(valor), "finito:", Number.isFinite(valor));
}
console.log("NaN es igual a sí mismo:", NaN === NaN);
console.log("Detectar NaN:", Number.isNaN(NaN));

Preferimos Number.isFinite y Number.isNaN porque no convierten textos automáticamente.

21.14 Casos límite geométricos

Una biblioteca vectorial debe decidir qué hace ante situaciones degeneradas:

  • Normalizar un vector nulo.
  • Proyectar sobre un vector nulo.
  • Calcular el ángulo si uno de los vectores no tiene dirección.
  • Construir una base con ejes paralelos.
  • Intersectar rectas paralelas o coincidentes.
  • Recibir componentes no finitas.

Devolver null, lanzar una excepción o entregar un objeto resultado con estado son contratos posibles. Devolver silenciosamente un número arbitrario oculta el problema.

21.15 Limitar dominios matemáticos

Por redondeo, un coseno calculado puede ser 1.0000000000000002. Como acos solo acepta valores entre −1 y 1, debemos limitar el dato antes de usarlo.

const limitar = (n, minimo, maximo) =>
  Math.max(minimo, Math.min(maximo, n));

const cosenoAproximado = 1.0000000000000002;
console.log("Sin limitar:", Math.acos(cosenoAproximado));
console.log("Limitado:", Math.acos(limitar(cosenoAproximado, -1, 1)));

Limitar es correcto cuando el valor excede el dominio solo por error numérico y la teoría garantiza que debería pertenecer a él. No debe usarse para esconder entradas físicamente inválidas.

21.16 Laboratorio de tolerancias

Elegí un experimento y cambiá la tolerancia. El gráfico amplifica el error para hacerlo visible; la barra amarilla representa su magnitud y la marca rosa, el umbral elegido.

Calculado
Esperado
Error absoluto
Tolerancia
Comparación exacta
Comparación aproximada

La igualdad exacta falla, pero la tolerancia reconoce valores equivalentes para esta escala.

21.17 Estrategia para una simulación estable

  1. Definir unidades y escalas antes de elegir tolerancias.
  2. Validar entradas externas con Number.isFinite.
  3. Tratar explícitamente operaciones degeneradas.
  4. Comparar resultados calculados con tolerancias justificadas.
  5. Evitar coordenadas desproporcionadamente grandes si se necesita detalle local.
  6. Medir invariantes durante ejecuciones largas.
  7. Registrar el primer valor no finito cerca de su origen.

Una tolerancia no corrige por sí sola un algoritmo inestable. Si el error crece en cada paso, también puede ser necesario reducir dt, cambiar el integrador o reformular el cálculo.

21.18 Errores frecuentes

  • Comparar con === un resultado obtenido mediante varios cálculos decimales.
  • Usar Number.EPSILON como tolerancia universal.
  • Aplicar solo tolerancia relativa cerca de cero.
  • Elegir una tolerancia sin considerar unidades ni escala.
  • Normalizar vectores diminutos y amplificar el ruido.
  • Ocultar entradas inválidas mediante clamp.
  • Permitir que NaN avance por toda la simulación.
  • Redondear el estado interno en cada cuadro solo para mejorar su presentación.
  • Confundir cantidad de decimales mostrados con precisión almacenada.

21.19 Ejercicio integrador

Implementá anguloEntreSeguro(a, b, tolerancia). Debe validar componentes finitas, devolver null si algún vector es casi nulo y limitar el coseno antes de llamar a Math.acos.

Ver solución y pruebas
function anguloEntreSeguro(a, b, tolerancia = 1e-12) {
  const componentes = [a.x, a.y, b.x, b.y];
  if (!componentes.every(Number.isFinite)) {
    throw new TypeError("Las componentes deben ser finitas");
  }
  const ma = Math.hypot(a.x, a.y);
  const mb = Math.hypot(b.x, b.y);
  if (ma <= tolerancia || mb <= tolerancia) return null;
  const coseno = (a.x * b.x + a.y * b.y) / (ma * mb);
  return Math.acos(Math.max(-1, Math.min(1, coseno)));
}

const recto = anguloEntreSeguro({x:1,y:0}, {x:0,y:1});
console.log("Ángulo recto en grados:", recto * 180 / Math.PI);
console.log("Con vector casi nulo:", anguloEntreSeguro(
  {x:1e-15,y:0}, {x:1,y:0}
));

21.20 Ideas para recordar

  • Los números de coma flotante aproximan muchos valores reales.
  • Number.EPSILON describe el espaciado cerca de 1, no el error permitido por toda aplicación.
  • Una comparación general combina tolerancias absoluta y relativa.
  • Las tolerancias deben derivarse de unidades, escalas y requisitos físicos.
  • Los vectores casi nulos requieren tratamiento antes de normalizar o dividir.
  • El orden de las operaciones puede afectar la precisión.
  • NaN e infinitos deben detectarse cerca de su origen.
  • Una API confiable documenta cada caso degenerado.

En el próximo tema llevaremos los vectores a una visualización interactiva con Canvas, transformando coordenadas físicas en píxeles y dibujando ejes, flechas y animaciones.