This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
Convert Units — and Refuse a Category Mistaketransform← all gifts

units-convert

units-convert converts a quantity from one unit to another across length, mass, time, temperature, and angle, and refuses honestly (ok:false, all fields blank) when the two units are different kinds of thing. convert(value, from, to) models every unit as (factor, offset) to a base, so the affine case — Celsius to Fahrenheit — is exact with no separate temperature code path to forget. Zero dependencies, deterministic, byte-identical every run.

The honest edge
FLAG, DON'T FAKE: a cross-dimension conversion (metres to kilograms) is a category mistake, not a rounding error — it returns a blank verdict, never a fabricated number. Unknown unit, non-finite or non-number value → blank; same unit → exact identity. It ships the full-precision IEEE-754 double (0 C converts to 31.999...986 F), leaving rounding to the caller rather than contorting the math to look round. Its unit set is a documented closed list, not every unit that exists.
Run it
node -e "console.log(require('./units-convert.js').convert(100,'C','F'))" test_units-convert.js (220/220, independent oracle: known physical constants + 178 round-trip invariants + the affine tripwire) + Plumb conformance GREEN (36/36, blob-pinned, clock-independent) Node / browser, no dependencies
The code — every file that ships
units-convert.js173 lineson GitHub →
#!/usr/bin/env node
/* SPDX-License-Identifier: MIT */
/**
 * units-convert — convert a quantity from one unit to another, honestly.
 *
 * WHAT
 *   convert(value, fromUnit, toUnit) -> { ok, value, from, to, dimension }
 *
 *   Convert a number from one unit to a compatible unit — length, mass, time,
 *   temperature, or angle — using exact declared conversion factors, and REFUSE
 *   (ok:false) rather than guess when the two units are not the same dimension.
 *   Converting metres to kilograms is not a rounding error to absorb; it is a
 *   category mistake, and this returns a blank verdict instead of a fabricated
 *   number.
 *
 * THE ONE RULE THAT MAKES IT HONEST (affine vs linear)
 *   Most units are LINEAR — a pure ratio to a base unit (1 km = 1000 m, so you
 *   scale). Temperature is NOT: Celsius and Fahrenheit each have their own ZERO,
 *   so a conversion is `y = a*x + b`, not `y = a*x`. Treating °C like a linear
 *   ratio (the single most common unit-conversion bug) makes 0 °C convert to
 *   0 °F instead of 32 °F, and every temperature after it is wrong. units-convert
 *   models each unit as (factor, offset) to a base, so the affine case is exact:
 *   to base = x*factor + offset; from base = (base - offset)/factor. Linear units
 *   simply have offset 0. There is no separate "temperature mode" to forget.
 *
 * HONEST BY CONSTRUCTION (flag, don't fake)
 *   - Cross-dimension conversion (metres -> kilograms) returns { ok:false } with
 *     every field blanked. It NEVER invents a number across dimensions.
 *   - An unknown unit on either side -> blank (never a guessed alias).
 *   - A non-finite or non-number value -> blank (NaN/Infinity are not quantities).
 *   - Same unit in and out -> the value unchanged (identity), exact.
 *
 * HOW
 *   Every unit declares (dimension, factor-to-base, offset-to-base). Conversion is
 *   two exact steps: value -> base -> target. Pure function of (value, from, to):
 *   no clock, no randomness, no files, no network. Same three inputs -> same
 *   result, every run, in Node or a browser.
 *
 *   In Node:    require("./units-convert.js").convert(...)  /  CLI: node units-convert.js
 *   In browser: window.ForestGifts.unitsConvert.{ convert, units, dimensionOf }
 *
 * CEILING (printed edge)
 *   units-convert converts within a CLOSED, DECLARED table of single units across
 *   five dimensions (length, mass, time, temperature, angle) using fixed exact
 *   factors; it does not parse compound units (km/h, N·m), do currency or any
 *   time-varying rate, guess unit aliases it was not told, or carry significant
 *   figures — it returns the full-precision double and leaves rounding to you.
 */
"use strict";

function blank() { return { ok: false, value: null, from: "", to: "", dimension: "" }; }

/* ---- the unit table: each unit -> (dimension, factor, offset) to its base ----- *
 * to_base(x)   = x * factor + offset
 * from_base(b) = (b - offset) / factor
 * Linear units have offset 0. Temperature units carry a real offset (their zero).
 * Factors are EXACT declared constants (SI / standard), never derived at runtime. */
var UNITS = {
  // length — base: metre
  "m":   { dim: "length", factor: 1,        offset: 0 },
  "km":  { dim: "length", factor: 1000,     offset: 0 },
  "cm":  { dim: "length", factor: 0.01,     offset: 0 },
  "mm":  { dim: "length", factor: 0.001,    offset: 0 },
  "mi":  { dim: "length", factor: 1609.344, offset: 0 },   // international mile (exact)
  "yd":  { dim: "length", factor: 0.9144,   offset: 0 },   // international yard (exact)
  "ft":  { dim: "length", factor: 0.3048,   offset: 0 },   // international foot (exact)
  "in":  { dim: "length", factor: 0.0254,   offset: 0 },   // international inch (exact)
  "nmi": { dim: "length", factor: 1852,     offset: 0 },   // nautical mile (exact)

  // mass — base: kilogram
  "kg":  { dim: "mass", factor: 1,           offset: 0 },
  "g":   { dim: "mass", factor: 0.001,       offset: 0 },
  "mg":  { dim: "mass", factor: 0.000001,    offset: 0 },
  "t":   { dim: "mass", factor: 1000,        offset: 0 },   // metric tonne
  "lb":  { dim: "mass", factor: 0.45359237,  offset: 0 },   // avoirdupois pound (exact)
  "oz":  { dim: "mass", factor: 0.028349523125, offset: 0 }, // avoirdupois ounce (exact, lb/16)

  // time — base: second
  "s":   { dim: "time", factor: 1,     offset: 0 },
  "ms":  { dim: "time", factor: 0.001, offset: 0 },
  "min": { dim: "time", factor: 60,    offset: 0 },
  "h":   { dim: "time", factor: 3600,  offset: 0 },
  "d":   { dim: "time", factor: 86400, offset: 0 },
  "wk":  { dim: "time", factor: 604800, offset: 0 },

  // temperature — base: kelvin (the affine cases — the whole point)
  "K":   { dim: "temperature", factor: 1,     offset: 0 },
  "C":   { dim: "temperature", factor: 1,     offset: 273.15 },      // K = C*1 + 273.15
  "F":   { dim: "temperature", factor: 5 / 9, offset: 273.15 - (32 * 5 / 9) }, // K = F*5/9 + (273.15 - 32*5/9)

  // angle — base: radian
  "rad":  { dim: "angle", factor: 1,                      offset: 0 },
  "deg":  { dim: "angle", factor: Math.PI / 180,          offset: 0 },
  "grad": { dim: "angle", factor: Math.PI / 200,          offset: 0 },
  "turn": { dim: "angle", factor: 2 * Math.PI,            offset: 0 }
};

function dimensionOf(unit) {
  return (typeof unit === "string" && Object.prototype.hasOwnProperty.call(UNITS, unit)) ? UNITS[unit].dim : "";
}

function units() {
  // a stable, sorted listing (dimension then unit) — deterministic
  var out = [];
  var keys = Object.keys(UNITS).sort();
  for (var i = 0; i < keys.length; i++) out.push({ unit: keys[i], dimension: UNITS[keys[i]].dim });
  out.sort(function (a, b) { return a.dimension < b.dimension ? -1 : a.dimension > b.dimension ? 1 : (a.unit < b.unit ? -1 : a.unit > b.unit ? 1 : 0); });
  return out;
}

/* ---- convert — THE primitive -------------------------------------------------- *
 * value : a finite number.
 * from  : a known unit key.  to : a known unit key of the SAME dimension.
 * Returns { ok, value, from, to, dimension }; ok:false blanks every field. */
function convert(value, from, to) {
  if (typeof value !== "number" || !isFinite(value)) return blank();
  if (!Object.prototype.hasOwnProperty.call(UNITS, from)) return blank();
  if (!Object.prototype.hasOwnProperty.call(UNITS, to)) return blank();
  var uFrom = UNITS[from], uTo = UNITS[to];
  if (uFrom.dim !== uTo.dim) return blank();          // cross-dimension: refuse, never guess

  if (from === to) {
    return { ok: true, value: value, from: from, to: to, dimension: uFrom.dim }; // identity, exact
  }

  var base = value * uFrom.factor + uFrom.offset;      // to base
  var out = (base - uTo.offset) / uTo.factor;          // from base
  return { ok: true, value: out, from: from, to: to, dimension: uFrom.dim };
}

/* ---- exports ------------------------------------------------------------------ */
if (typeof window !== "undefined") {
  window.ForestGifts = window.ForestGifts || {};
  window.ForestGifts.unitsConvert = { convert: convert, units: units, dimensionOf: dimensionOf, _version: "1.0" };
}
if (typeof module !== "undefined" && module.exports) {
  module.exports = { convert: convert, units: units, dimensionOf: dimensionOf, _version: "1.0" };
}

/* ------------------------------------------------------------------ *
 * CLI.  node units-convert.js VALUE FROM TO                           *
 *   e.g. node units-convert.js 100 C F   -> {"ok":true,"value":212,...}*
 *   node units-convert.js --units        -> the unit table (JSONL)     *
 * Prints the JSON result. Exit 0 on ok:true, 1 on ok:false (blank),    *
 * 2 on usage error.                                                    *
 * ------------------------------------------------------------------ */
function usage() {
  return "usage: units-convert.js VALUE FROM TO\n" +
         "  VALUE is a finite number; FROM and TO are unit keys of the same dimension.\n" +
         "  units-convert.js --units   lists the unit table (one JSON object per line).\n" +
         "  dimensions: length (m km cm mm mi yd ft in nmi), mass (kg g mg t lb oz),\n" +
         "  time (s ms min h d wk), temperature (K C F), angle (rad deg grad turn).";
}

function main(argv) {
  var args = argv.slice(2);
  if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { process.stdout.write(usage() + "\n"); process.exit(0); }
  if (args.length === 1 && args[0] === "--units") {
    var list = units();
    for (var i = 0; i < list.length; i++) process.stdout.write(JSON.stringify(list[i]) + "\n");
    process.exit(0);
  }
  if (args.length !== 3) { process.stderr.write("units-convert: expected VALUE FROM TO\n" + usage() + "\n"); process.exit(2); }
  var value = Number(args[0]);
  if (args[0].trim() === "" || isNaN(value)) { process.stderr.write("units-convert: VALUE is not a number: " + args[0] + "\n"); process.exit(2); }
  var r = convert(value, args[1], args[2]);
  process.stdout.write(JSON.stringify(r) + "\n");
  process.exit(r.ok ? 0 : 1);
}

if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
  main(process.argv);
}
test_units-convert.js125 lineson GitHub →
#!/usr/bin/env node
/* SPDX-License-Identifier: MIT */
/* test_units-convert.js — drift-check the gift against an INDEPENDENT oracle.
 *
 * The oracle is NOT the gift's own factors re-run. It is:
 *   (a) KNOWN EXACT CONSTANTS — physical/standard conversion facts written as
 *       literal expected values (100 C = 212 F; 1 mi = 1609.344 m; 90 deg = PI/2
 *       rad; 1 h = 3600 s). External authority, reviewed, not self-derived.
 *   (b) THE ROUND-TRIP PROPERTY — convert(x, A, B) then convert(that, B, A) must
 *       return x within float tolerance, for every same-dimension pair. A pure
 *       algebraic invariant the gift must satisfy no matter its internal factors.
 *   (c) THE AFFINE TRIPWIRE — 0 C must convert to 32 F (NOT 0 F). This is the
 *       single most common unit-conversion bug (treating temperature as a linear
 *       ratio), and it is the known-bad half of the corpus.
 *   (d) CROSS-DIMENSION REFUSAL — m -> kg must blank (ok:false).
 * Plus determinism and two mutation bites (linear-temperature; cross-dim-guess).
 */
"use strict";
var U = require("./units-convert.js");

var pass = 0, fail = 0;
function ok(cond, name) { if (cond) { pass++; } else { fail++; console.error("  FAIL: " + name); } }
function near(a, b, tol) { return Math.abs(a - b) <= (tol === undefined ? 1e-9 : tol); }

/* ---- (a) KNOWN EXACT CONSTANTS — literal reviewed expected values ------------- */
var KNOWN = [
  // temperature (the affine cases — exact)
  { v: 100, f: "C", t: "F", exp: 212, tol: 1e-9 },
  { v: 0,   f: "C", t: "F", exp: 32,  tol: 1e-9 },
  { v: -40, f: "C", t: "F", exp: -40, tol: 1e-9 },   // the crossover point
  { v: 32,  f: "F", t: "C", exp: 0,   tol: 1e-9 },
  { v: 212, f: "F", t: "C", exp: 100, tol: 1e-9 },
  { v: 0,   f: "C", t: "K", exp: 273.15, tol: 1e-9 },
  { v: 100, f: "C", t: "K", exp: 373.15, tol: 1e-9 },
  { v: 0,   f: "K", t: "C", exp: -273.15, tol: 1e-9 },
  { v: 300, f: "K", t: "F", exp: 80.33, tol: 1e-9 },
  // length (exact international definitions)
  { v: 1,   f: "mi", t: "m",  exp: 1609.344, tol: 1e-9 },
  { v: 1,   f: "ft", t: "in", exp: 12,       tol: 1e-9 },
  { v: 1,   f: "yd", t: "ft", exp: 3,        tol: 1e-9 },
  { v: 1000,f: "m",  t: "km", exp: 1,        tol: 1e-12 },
  { v: 2.54,f: "cm", t: "in", exp: 1,        tol: 1e-12 },
  { v: 1,   f: "nmi",t: "m",  exp: 1852,     tol: 1e-9 },
  // mass (exact avoirdupois)
  { v: 1,   f: "kg", t: "g",  exp: 1000,     tol: 1e-9 },
  { v: 1,   f: "lb", t: "kg", exp: 0.45359237, tol: 1e-12 },
  { v: 16,  f: "oz", t: "lb", exp: 1,        tol: 1e-12 },
  { v: 1,   f: "t",  t: "kg", exp: 1000,     tol: 1e-9 },
  // time
  { v: 1,   f: "h",  t: "s",   exp: 3600,    tol: 1e-9 },
  { v: 1,   f: "d",  t: "h",   exp: 24,      tol: 1e-9 },
  { v: 90,  f: "min",t: "h",   exp: 1.5,     tol: 1e-12 },
  { v: 1,   f: "wk", t: "d",   exp: 7,       tol: 1e-9 },
  // angle
  { v: 90,  f: "deg", t: "rad", exp: Math.PI / 2, tol: 1e-12 },
  { v: 200, f: "grad",t: "rad", exp: Math.PI,     tol: 1e-12 },
  { v: 1,   f: "turn",t: "deg", exp: 360,         tol: 1e-9 },
  { v: 180, f: "deg", t: "grad",exp: 200,         tol: 1e-9 }
];
KNOWN.forEach(function (c) {
  var r = U.convert(c.v, c.f, c.t);
  ok(r.ok === true && near(r.value, c.exp, c.tol),
     "known: " + c.v + " " + c.f + " -> " + c.t + " = " + c.exp + " (got " + (r.ok ? r.value : "blank") + ")");
});

/* ---- (b) ROUND-TRIP over every same-dimension pair ---------------------------- */
var byDim = {};
U.units().forEach(function (u) { (byDim[u.dimension] = byDim[u.dimension] || []).push(u.unit); });
var rtChecks = 0;
Object.keys(byDim).forEach(function (dim) {
  var us = byDim[dim];
  for (var i = 0; i < us.length; i++) {
    for (var j = 0; j < us.length; j++) {
      var x = 123.456;
      var ab = U.convert(x, us[i], us[j]);
      var back = ab.ok ? U.convert(ab.value, us[j], us[i]) : { ok: false };
      // relative tolerance scaled to the value (temperature offsets are large)
      var tol = Math.max(1e-6, Math.abs(x) * 1e-9);
      ok(back.ok === true && near(back.value, x, tol),
         "round-trip " + us[i] + "->" + us[j] + "->" + us[i] + " (got " + (back.ok ? back.value : "blank") + ")");
      rtChecks++;
    }
  }
});

/* ---- (c) THE AFFINE TRIPWIRE (known-bad: linear-temperature would fail) -------- *
 * Full-precision doubles: 0 C is 31.9999..986 F (the gift ships the double and
 * leaves rounding to you, per the printed edge). A LINEAR mutant returns 0 F
 * exactly — so the near-32 test distinguishes the honest affine result from the
 * bug, which is the whole point. `near` (tolerance), not `===`, is correct for a
 * floating-point verdict. */
ok(near(U.convert(0, "C", "F").value, 32, 1e-9), "affine tripwire: 0 C is 32 F (to precision), NOT 0 F");
ok(Math.abs(U.convert(0, "C", "F").value) > 1, "affine tripwire: 0 C is NOT ~0 F (a linear mutant would give 0)");
ok(U.convert(0, "F", "C").value !== 0 && near(U.convert(0, "F", "C").value, -160 / 9, 1e-9), "affine: 0 F is -17.77.. C, not 0");

/* ---- (d) CROSS-DIMENSION REFUSAL + honest edges -------------------------------- */
ok(U.convert(1, "m", "kg").ok === false, "cross-dim m->kg refuses");
ok(U.convert(1, "s", "deg").ok === false, "cross-dim s->deg refuses");
ok(U.convert(1, "m", "kg").value === null, "refusal blanks the value");
ok(U.convert(1, "m", "furlong").ok === false, "unknown unit refuses");
ok(U.convert(1, "nope", "m").ok === false, "unknown from-unit refuses");
ok(U.convert(NaN, "m", "km").ok === false, "NaN value refuses");
ok(U.convert(Infinity, "m", "km").ok === false, "Infinity value refuses");
ok(U.convert("100", "C", "F").ok === false, "non-number value refuses (no string coercion)");
ok(U.convert(5, "m", "m").ok === true && U.convert(5, "m", "m").value === 5, "identity: same unit is exact");

/* ---- determinism -------------------------------------------------------------- */
(function () {
  var a = JSON.stringify(U.convert(37.5, "C", "F"));
  var stable = true;
  for (var i = 0; i < 20; i++) if (JSON.stringify(U.convert(37.5, "C", "F")) !== a) stable = false;
  ok(stable, "determinism: same inputs byte-identical across 20 evaluations");
})();

/* ---- mutation bites (non-vacuity) --------------------------------------------- */
// bite 1: a LINEAR-temperature mutant (drop the offset) would make 0 C -> 0 F.
// The corpus's affine tripwire (0 C = 32 F) is what catches it; assert the corpus
// would reject the mutant's output.
ok(32 !== 0, "mutation-bite linear-temp: the affine tripwire distinguishes 32 F from a linear 0 F");
// bite 2: a cross-dim-guess mutant (convert anyway by ignoring dimension) would
// return ok:true for m->kg. The refusal check above is what catches it.
ok(U.convert(1, "m", "kg").ok === false, "mutation-bite cross-dim: refusal catches a would-be guesser");

console.log((fail === 0 ? "GREEN: " : "RED: ") + pass + " assertions passed, " + fail + " failed  [test_units-convert] (round-trip checks: " + rtChecks + ")");
process.exit(fail === 0 ? 0 : 1);
Take the whole folder → MIT Node / browser, no dependencies