AXLR / Catalog / personal

unit_price_compare

Compare the price per unit of several offers with different sizes and units (g, kg, oz, lb; ml, l, US and imperial fl oz, pints, gallons; items and dozens), multipacks and deals such as "3 for 2", "2 for 5.00", "buy 2 get 1 free" or percent off: ranks them, names the cheapest per unit and what each other offer costs extra. Decimal arithmetic with exact NIST unit factors from a bundled dated table.

Install

/tools install unit_price_compare
/tools install unit_price_compare@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · unit_price_compare 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool unit_price_compare; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
offersarray of objectnoitems ≥ 1; items ≤ 200
perstringnoUnit of the unit price, with an optional amount: "kg", "100 g", "l" (default kg, l or count)
placesintegerno4≥ 0; ≤ 12
currencystringno
list_unitsbooleannoList the bundled units and their sources
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "offers": {
      "type": "array",
      "minItems": 1,
      "maxItems": 200,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "price",
          "size",
          "unit"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "price": {
            "type": [
              "number",
              "string"
            ],
            "description": "Price of one pack as sold"
          },
          "size": {
            "type": [
              "number",
              "string"
            ],
            "description": "Quantity in one item of the pack"
          },
          "unit": {
            "type": "string",
            "description": "g, kg, mg, oz, lb, ml, cl, dl, l, fl_oz, pt, qt, gal, uk_fl_oz, uk_pint, uk_gal, count, dozen (and aliases)"
          },
          "count": {
            "type": "integer",
            "minimum": 1,
            "default": 1,
            "description": "Items in a multipack"
          },
          "deal": {
            "type": [
              "string",
              "object"
            ],
            "description": "\"3 for 2\", \"2 for 5.00\", \"buy 2 get 1 free\", \"bogo\", {buy, pay_for} or {buy, total_price}"
          },
          "discount_pct": {
            "type": [
              "number",
              "string"
            ],
            "description": "Percent off each pack"
          },
          "discount_amount": {
            "type": [
              "number",
              "string"
            ],
            "description": "Amount off each pack"
          }
        }
      }
    },
    "per": {
      "type": "string",
      "description": "Unit of the unit price, with an optional amount: \"kg\", \"100 g\", \"l\" (default kg, l or count)"
    },
    "places": {
      "type": "integer",
      "minimum": 0,
      "maximum": 12,
      "default": 4
    },
    "currency": {
      "type": "string"
    },
    "list_units": {
      "type": "boolean",
      "description": "List the bundled units and their sources"
    }
  }
}

Example

Input

{
  "offers": [
    {
      "name": "small",
      "price": "2.50",
      "size": 500,
      "unit": "g"
    },
    {
      "name": "large",
      "price": "4.50",
      "size": 1,
      "unit": "kg"
    },
    {
      "name": "deal",
      "price": "1.20",
      "size": 400,
      "unit": "g",
      "deal": "3 for 2"
    }
  ]
}

Output (stdout)

{
  "best": "deal",
  "best_unit_price": "2.0000",
  "per": "kg"
}

Files

The command the install card registers is python3 unit_price_compare.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

unit_price_compare.py · sha256 9dbb52e7f8b9c4a37171b38dd359846b23e51a4968712cc91192357a4e3ec762
#!/usr/bin/env python3
"""Unit-price comparison: the price per unit of several offers with different package sizes and
units (g, kg, oz, lb; ml, l, US and imperial fl oz; items), multipacks and deals ("3 for 2",
"2 for 5.00", "buy 2 get 1 free", percent or amount off), the cheapest offer per unit and what
each other offer costs extra. Decimal arithmetic; unit factors from the bundled units.json.

Input (JSON on stdin):
  offers     [{"name", "price", "size", "unit", "count"?, "deal"?, "discount_pct"?, "discount_amount"?}]
             price   price of ONE pack as sold (number or decimal string)
             size    quantity in one item of the pack; unit its unit
             count   items in the pack (multipack), default 1
             deal    "N for M" (pay for M of N packs when M < N is a whole number; otherwise N packs
                     for a total price M), "buy N get K free", "bogo", or {"buy": N, "pay_for": M}
                     or {"buy": N, "total_price": P}
             discount_pct / discount_amount   off the price of each pack, applied before the deal
  per        unit to express prices in, with an optional number: "kg", "100 g", "l", "fl_oz"
             (default kg, l or item, by the family of the offers)
  places     decimal places of the unit prices (default 4)
  currency   label copied to the output
  list_units true: list the bundled units
"""
import json
import re
import sys
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation, getcontext

getcontext().prec = 50


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def load_table():
    try:
        with open("units.json", encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, ValueError) as error:
        fail(f"cannot read the bundled units.json: {error}")


def dec(value, field, positive=False, allow_zero=True):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a decimal string")
    text = str(value).strip().replace(",", "") if isinstance(value, str) else str(value)
    text = text.lstrip("$€£¥")
    try:
        number = Decimal(text)
    except InvalidOperation:
        fail(f"{field} must be a number or a decimal string")
    if not number.is_finite():
        fail(f"{field} must be finite")
    if number < 0 or (positive and number == 0) or (not allow_zero and number == 0):
        fail(f"{field} must be {'positive' if positive else 'zero or more'}")
    return number


def lookup(units, name, field):
    if not isinstance(name, str) or not name.strip():
        fail(f"{field} must be a unit name")
    key = name.strip()
    if key in units:
        return key
    for symbol, info in units.items():
        if key in info["aliases"] or key.lower() == symbol.lower() or key.lower() in [a.lower() for a in info["aliases"]]:
            return symbol
    fail(f"{field}: unknown unit {name!r}; known: {', '.join(units)}")


def parse_deal(deal, field):
    """Return (packs bought, packs paid for or None, total price or None, label)."""
    if deal is None:
        return 1, 1, None, None
    if isinstance(deal, dict):
        extra = set(deal) - {"buy", "pay_for", "total_price"}
        if extra:
            fail(f"{field}: unknown keys {sorted(extra)}")
        buy = deal.get("buy")
        if isinstance(buy, bool) or not isinstance(buy, int) or buy < 1:
            fail(f"{field}.buy must be a positive integer")
        if "pay_for" in deal and "total_price" not in deal:
            pay = deal["pay_for"]
            if isinstance(pay, bool) or not isinstance(pay, int) or not 0 < pay <= buy:
                fail(f"{field}.pay_for must be an integer from 1 to buy")
            return buy, pay, None, f"{buy} for the price of {pay}"
        if "total_price" in deal and "pay_for" not in deal:
            total = dec(deal["total_price"], f"{field}.total_price", positive=True)
            return buy, None, total, f"{buy} for {total}"
        fail(f"{field} needs pay_for or total_price (one of them)")
    if not isinstance(deal, str):
        fail(f"{field} must be a string or an object")
    text = deal.strip().lower()
    if text in ("bogo", "bogof", "buy one get one free", "buy 1 get 1 free", "2 for 1"):
        return 2, 1, None, "2 for the price of 1"
    match = re.fullmatch(r"buy\s+(\d+)\s*,?\s*get\s+(\d+)\s+free", text)
    if match:
        buy, free = int(match.group(1)), int(match.group(2))
        if buy < 1 or free < 1:
            fail(f"{field}: the numbers must be positive")
        return buy + free, buy, None, f"{buy + free} for the price of {buy}"
    match = re.fullmatch(r"(\d+)\s*(?:for|x)\s*([$€£¥]?)\s*(\d+(?:\.\d+)?)", text)
    if match:
        buy, symbol, amount = int(match.group(1)), match.group(2), match.group(3)
        if buy < 1:
            fail(f"{field}: the number of packs must be positive")
        if not symbol and "." not in amount and 0 < int(amount) < buy:
            return buy, int(amount), None, f"{buy} for the price of {int(amount)}"
        total = dec(amount, field, positive=True)
        return buy, None, total, f"{buy} for {total}"
    fail(f"{field}: cannot read deal {deal!r} (use \"3 for 2\", \"2 for 5.00\", \"buy 2 get 1 free\", \"bogo\" or an object)")


def parse_per(text, units, family_unit):
    if text is None:
        return Decimal(1), family_unit
    if not isinstance(text, str):
        fail("per must be a string such as \"kg\" or \"100 g\"")
    match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)?\s*(.+?)\s*", text)
    amount = Decimal(match.group(1)) if match.group(1) else Decimal(1)
    if amount <= 0:
        fail("per: the amount must be positive")
    return amount, lookup(units, match.group(2), "per")


def fmt(x, places):
    q = Decimal(1).scaleb(-places)
    return format(x.quantize(q, ROUND_HALF_UP), "f")


def plain(x):
    s = format(x.normalize(), "f")
    return s


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    allowed = {"offers", "per", "places", "currency", "list_units"}
    extra = set(request) - allowed
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    table = load_table()
    units = table["units"]
    if request.get("list_units"):
        print(json.dumps({"units": {k: {"family": v["family"], "factor_to_base": v["factor"], "base": table["base_units"][v["family"]],
                                        "aliases": v["aliases"], "definition": v["definition"], "source": v["source"]}
                                    for k, v in units.items()},
                          "table": {"generated": table["generated"], "source": table["source"]}}))
        return
    offers = request.get("offers")
    if not isinstance(offers, list) or len(offers) < 1:
        fail("offers must be a non-empty list")
    if len(offers) > 200:
        fail("at most 200 offers")
    places = request.get("places", 4)
    if isinstance(places, bool) or not isinstance(places, int) or not 0 <= places <= 12:
        fail("places must be an integer from 0 to 12")
    currency = request.get("currency")
    if currency is not None and not isinstance(currency, str):
        fail("currency must be a string")

    rows, families = [], set()
    for i, offer in enumerate(offers):
        field = f"offers[{i}]"
        if not isinstance(offer, dict):
            fail(f"{field} must be an object")
        extra = set(offer) - {"name", "price", "size", "unit", "count", "deal", "discount_pct", "discount_amount"}
        if extra:
            fail(f"{field}: unknown keys {sorted(extra)}")
        name = offer.get("name", f"offer {i + 1}")
        if not isinstance(name, str):
            fail(f"{field}.name must be a string")
        if "price" not in offer or "size" not in offer or "unit" not in offer:
            fail(f"{field} needs price, size and unit")
        price = dec(offer["price"], f"{field}.price")
        size = dec(offer["size"], f"{field}.size", positive=True)
        unit = lookup(units, offer["unit"], f"{field}.unit")
        count = offer.get("count", 1)
        if isinstance(count, bool) or not isinstance(count, int) or count < 1:
            fail(f"{field}.count must be a positive integer")
        pack_price = price
        notes = []
        if "discount_pct" in offer:
            pct = dec(offer["discount_pct"], f"{field}.discount_pct")
            if pct > 100:
                fail(f"{field}.discount_pct must be at most 100")
            pack_price = pack_price * (100 - pct) / 100
            notes.append(f"{plain(pct)} % off")
        if "discount_amount" in offer:
            amount = dec(offer["discount_amount"], f"{field}.discount_amount")
            if amount > pack_price:
                fail(f"{field}.discount_amount is more than the price")
            pack_price -= amount
            notes.append(f"{plain(amount)} off")
        buy, pay_for, total_price, label = parse_deal(offer.get("deal"), f"{field}.deal")
        if total_price is not None:
            spend = total_price
        else:
            spend = pack_price * pay_for
        if label:
            notes.append(label)
        info = units[unit]
        families.add(info["family"])
        quantity_base = size * Decimal(info["factor"]) * count * buy
        rows.append({"name": name, "unit": unit, "family": info["family"], "spend": spend, "packs": buy,
                     "quantity_base": quantity_base, "notes": notes, "pack_price": pack_price, "count": count, "size": size})
    if len(families) > 1:
        fail(f"offers mix {' and '.join(sorted(families))}; only offers measured the same way can be compared")
    family = families.pop()
    default_per = {"mass": "kg", "volume": "l", "count": "count"}[family]
    per_amount, per_unit = parse_per(request.get("per"), units, default_per)
    if units[per_unit]["family"] != family:
        fail(f"per is a {units[per_unit]['family']} unit but the offers are {family}")
    per_base = per_amount * Decimal(units[per_unit]["factor"])
    per_label = (plain(per_amount) + " " if per_amount != 1 else "") + per_unit

    for row in rows:
        row["unit_price"] = row["spend"] / row["quantity_base"] * per_base
    best = min(rows, key=lambda r: r["unit_price"])
    best_price = best["unit_price"]
    out_rows = []
    for rank, row in enumerate(sorted(rows, key=lambda r: r["unit_price"]), 1):
        extra_pct = (row["unit_price"] - best_price) / best_price * 100 if best_price else Decimal(0)
        entry = {
            "rank": rank, "name": row["name"],
            "packs_to_buy": row["packs"], "pay": fmt(row["spend"], 2),
            "quantity": plain(row["quantity_base"] / per_base * per_amount) + " " + per_unit,
            "quantity_base": plain(row["quantity_base"]) + " " + table["base_units"][family],
            "unit_price": fmt(row["unit_price"], places),
            "extra_vs_best_pct": fmt(extra_pct, 2),
            "extra_per_unit_vs_best": fmt(row["unit_price"] - best_price, places),
        }
        if row["notes"]:
            entry["deal"] = "; ".join(row["notes"])
        if row is not best:
            # cost of the best offer's quantity bought at this offer's unit price, minus the best's spend
            entry["savings_choosing_best_for_same_quantity"] = fmt(row["unit_price"] / per_base * best["quantity_base"] - best["spend"], 2)
        out_rows.append(entry)
    result = {"family": family, "per": per_label, "best": best["name"], "best_unit_price": fmt(best_price, places),
              "ranking": out_rows,
              "table": {"generated": table["generated"], "source": table["source"]}}
    ties = [r["name"] for r in rows if r["unit_price"] == best_price and r is not best]
    if ties:
        result["tied_with_best"] = ties
    if currency:
        result["currency"] = currency
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()
units.json · sha256 be2dc6642b54362a049f7bc8694c75bba186f538f56de7cea05cdeaaac5776d2
{
 "generated": "2026-10-18",
 "generator": "tools/catalog/data/unit_price_compare.py",
 "source": "NIST SP 811 (2008) App. B.8; NIST Handbook 44 (2024) App. C; BIPM SI Brochure 9th ed.",
 "licence": "NIST: US Government work, public domain (17 U.S.C. 105); SI Brochure: CC BY 3.0 IGO; numerical definitions only",
 "base_units": {
  "mass": "g",
  "volume": "ml",
  "count": "count"
 },
 "units": {
  "mg": {
   "family": "mass",
   "factor": "0.001",
   "aliases": [
    "milligram",
    "milligrams"
   ],
   "definition": "10^-3 g",
   "source": "SI Brochure Table 7"
  },
  "g": {
   "family": "mass",
   "factor": "1",
   "aliases": [
    "gram",
    "grams"
   ],
   "definition": "base unit of this table",
   "source": "SI Brochure Table 2"
  },
  "kg": {
   "family": "mass",
   "factor": "1000",
   "aliases": [
    "kilogram",
    "kilograms",
    "kilo",
    "kilos"
   ],
   "definition": "1000 g",
   "source": "SI Brochure Table 2"
  },
  "oz": {
   "family": "mass",
   "factor": "28.349523125",
   "aliases": [
    "ounce",
    "ounces",
    "oz_av"
   ],
   "definition": "1/16 lb = 28.349523125 g",
   "source": "NIST SP 811 App. B.8"
  },
  "lb": {
   "family": "mass",
   "factor": "453.59237",
   "aliases": [
    "lbs",
    "pound",
    "pounds"
   ],
   "definition": "0.45359237 kg exactly",
   "source": "NIST SP 811 App. B.8 (1959 agreement)"
  },
  "ml": {
   "family": "volume",
   "factor": "1",
   "aliases": [
    "mL",
    "millilitre",
    "milliliter",
    "millilitres",
    "milliliters"
   ],
   "definition": "base unit of this table",
   "source": "SI Brochure Table 8"
  },
  "cl": {
   "family": "volume",
   "factor": "10",
   "aliases": [
    "cL",
    "centilitre",
    "centiliter"
   ],
   "definition": "10 mL",
   "source": "SI Brochure Tables 7, 8"
  },
  "dl": {
   "family": "volume",
   "factor": "100",
   "aliases": [
    "dL",
    "decilitre",
    "deciliter"
   ],
   "definition": "100 mL",
   "source": "SI Brochure Tables 7, 8"
  },
  "l": {
   "family": "volume",
   "factor": "1000",
   "aliases": [
    "L",
    "litre",
    "liter",
    "litres",
    "liters"
   ],
   "definition": "1 dm^3 = 1000 mL",
   "source": "SI Brochure Table 8"
  },
  "fl_oz": {
   "family": "volume",
   "factor": "29.5735295625",
   "aliases": [
    "floz",
    "fl oz",
    "us_fl_oz",
    "fluid ounce",
    "fluid ounces"
   ],
   "definition": "US fluid ounce, 1/128 US gal = 29.5735295625 mL",
   "source": "NIST Handbook 44 App. C"
  },
  "pt": {
   "family": "volume",
   "factor": "473.176473",
   "aliases": [
    "pint",
    "us_pint"
   ],
   "definition": "US liquid pint, 1/8 US gal",
   "source": "NIST Handbook 44 App. C"
  },
  "qt": {
   "family": "volume",
   "factor": "946.352946",
   "aliases": [
    "quart",
    "us_quart"
   ],
   "definition": "US liquid quart, 1/4 US gal",
   "source": "NIST Handbook 44 App. C"
  },
  "gal": {
   "family": "volume",
   "factor": "3785.411784",
   "aliases": [
    "gallon",
    "us_gal"
   ],
   "definition": "US gallon, 231 in^3 = 3.785411784 L",
   "source": "NIST Handbook 44 App. C"
  },
  "uk_fl_oz": {
   "family": "volume",
   "factor": "28.4130625",
   "aliases": [
    "imp_fl_oz",
    "imperial fluid ounce"
   ],
   "definition": "imperial fluid ounce, 1/160 imp gal = 28.4130625 mL",
   "source": "NIST SP 811 App. B.8"
  },
  "uk_pint": {
   "family": "volume",
   "factor": "568.26125",
   "aliases": [
    "imp_pint",
    "imperial pint"
   ],
   "definition": "imperial pint, 1/8 imp gal = 568.26125 mL",
   "source": "NIST SP 811 App. B.8"
  },
  "uk_gal": {
   "family": "volume",
   "factor": "4546.09",
   "aliases": [
    "imp_gal",
    "imperial gallon"
   ],
   "definition": "imperial gallon, 4.54609 L exactly",
   "source": "NIST SP 811 App. B.8"
  },
  "count": {
   "family": "count",
   "factor": "1",
   "aliases": [
    "each",
    "ea",
    "item",
    "items",
    "unit",
    "units",
    "pc",
    "pcs",
    "piece",
    "pieces",
    "ct"
   ],
   "definition": "one item",
   "source": "counting"
  },
  "dozen": {
   "family": "count",
   "factor": "12",
   "aliases": [
    "doz"
   ],
   "definition": "12 items",
   "source": "counting"
  }
 }
}

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/unit_price_compare/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/unit_price_compare/1.0.0/unit_price_compare.py
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/unit_price_compare/1.0.0/units.json