Skip to content

feat!: define sale-basis steps for quantities - #653

Draft
igrigorik wants to merge 6 commits into
mainfrom
feat/sale-basis
Draft

feat!: define sale-basis steps for quantities#653
igrigorik wants to merge 6 commits into
mainfrom
feat/sale-basis

Conversation

@igrigorik

Copy link
Copy Markdown
Contributor

Context: #597. This PR drafts alternate solution shape...


UCP's integer quantity has no denominator. It can represent two bottles, but not 1.50 kg of loose goods while preserving exact fulfillment, adjustment, and return arithmetic. This PR keeps quantity as an integer count of steps and adds quantity_unit to define their denomination and granularity. One step is 10^-scale of unit. The (unit, effective scale) pair is the unit descriptor's machine identity; it neither identifies the purchasable item nor exhaustively describes one sale unit. Omitting quantity_unit from authoritative data preserves the existing each basis.

  • Included: goods whose amount can be fulfilled, short-filled, adjusted, or returned; countable goods; packaged variants; transaction pricing and order lifecycle behavior.
  • Deferred: Buyer-provided dimensions (see below), explicit step restrictions beyond declared scale, multi-unit offers per variant, and B2B configurators with different pricing contracts.

This paves the way for...

1. Each or fixed package

quantity counts purchasable variants. Existing countable-goods payloads are unchanged:

{
  "item": { "id": "var_bottle" },
  "quantity": 2
}

2. Variable amount

When the measured amount itself participates in fulfillment and returns, quantity_unit denominates quantity. An authoritative USD transaction line for loose fasteners sold in hundredth-of-a-kilogram steps carries the per-kilogram price, sale basis, and quantity together:

{
  "id": "li_fasteners",
  "item": {
    "id": "var_fasteners",
    "title": "Stainless Steel Fasteners",
    "price": 1299,
    "quantity_unit": {
      "unit": "KGM",
      "scale": 2,
      "display_text": "kg"
    }
  },
  "quantity": 150,
  "totals": [
    { "type": "subtotal", "amount": 1949 },
    { "type": "total", "amount": 1949 }
  ]
}

Here quantity: 150 means 1.50 kg and item.price: 1299 is 1299 minor units per whole kilogram. The Business computes 1299 × 150 × 10^-2 = 1948.5 and rounds once to the authoritative line total of 1949 ($19.49).

The same steps flow through the order lifecycle:

{
  "quantity": {
    "original": 150,
    "total": 150,
    "fulfilled": 50
  },
  "status": "partial"
}

Here fulfilled: 50 means 0.50 kg and an adjustment of -25 means a 0.25 kg return—all in one inherited unit.

Key design decisions

  1. Integer steps, not decimals. Quantity participates in equality, accumulation, and signed adjustments. Integer steps provide the same exactness UCP requires for currency minor units.
  2. The unit-descriptor machine identity is (unit, effective scale). scale defaults to 0; required display_text is presentation-only. This identity defines the denomination, not the complete purchasable item. C62 represents each and cannot use a nonzero scale.
  3. No UCP unit ontology. Businesses use UN/CEFACT Rec20. A Business MAY use a custom identifier when no code fits, but MUST use it consistently. Platforms treat unknown identifiers as opaque and render display_text. Package codes are excluded because packages count as each.
  4. One inherited sale basis. Catalog advertises it; Cart and Checkout transact in it; Order applies it to lifecycle arithmetic. Referenced records inherit rather than redeclare the unit.
  5. Request omission makes no assertion. Platforms MAY assert (unit, effective scale). Businesses MUST echo non-each descriptors and surface mismatches; neither side silently converts quantities.
  6. Price is per whole unit. Per-step pricing could require fractional currency minor units. Other characteristics of a sale unit may affect the Business-quoted price without changing this denominator. Businesses compute price × quantity × 10^-scale, round once at the line, and return authoritative totals.

Compatibility and migration

Backward compatible:

  • quantity remains an integer everywhere.
  • quantity_unit is optional; omission retains the existing each wire representation.
  • Existing quantity, fulfillment, expectation, and adjustment field types do not change.

Breaking:

This is a Core Protocol feat! change because existing unit_price producers must migrate measure and reference from decimal/free-form measurements to positive integer values plus the shared descriptor (unit, display_text, optional scale). Their units and currencies must match, and zero or negative comparator measures are invalid. This removes a second unit vocabulary and numeric representation rather than preserving parallel paths.


Checklist

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors.
  • Capability: New schemas (Discovery, Cart, etc.) or extensions.
  • Documentation: Updates to README, or documentations regarding schema or capabilities.
  • I have followed the Contributing Guide
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.

   UCP's integer `quantity` had no denominator. It could represent two
   bottles, but not 1.50 kg of loose goods, while preserving exact
   fulfillment, adjustment, and return arithmetic.

   The commerce model distinguishes three cases:

   - Each or fixed package: `quantity` counts purchasable variants. A bottle
     or a 50 m cable spool sold as one package uses `quantity: 1` and omits
     `quantity_unit`. Its fixed contents are part of the variant identity;
     `unit_price` can still display a comparison such as price per metre.
   - Variable amount: the measured amount itself participates in
     fulfillment and returns. `quantity_unit: { unit: "KGM", scale: 2 }`
     makes each integer quantity step 0.01 kg, so `quantity: 150` means
     1.50 kg.
   - Units plus variable configuration: three made-to-measure curtains at
     58 cm each have two independent numbers. `quantity: 3` counts the
     curtains; 58 cm configures what each curtain is. Configured
     measurements are intentionally deferred rather than conflated with
     lifecycle quantity.

   Model the first two cases with a shared unit descriptor containing a
   machine `unit`, required `display_text`, and optional nonnegative
   `scale`. One step is `10^-scale` of the unit, and machine identity is the
   (`unit`, effective `scale`) pair. `display_text` is presentation data and
   does not participate in identity matching.

   Preserve `each` as the default sale basis. Omitting `quantity_unit` from
   authoritative data means (`C62`, 0), and `C62` cannot use a nonzero
   scale. This keeps existing countable-goods payloads unchanged.

   Prefer UN/CEFACT Recommendation 20 Common Codes without embedding a UCP
   unit ontology. When no code accurately identifies a unit, a Business
   may use a custom identifier but must use it consistently. Platforms
   treat unknown identifiers as opaque and render the supplied
   `display_text`. Package codes remain outside `quantity_unit`; package
   form belongs to variant identity and packages count as `each`.

   Define capability behavior around the shared representation:

   - Catalog advertises a variant's sale basis.
   - Cart and Checkout interpret request omission as no unit assertion.
     Platforms may assert a (`unit`, effective `scale`) identity, and
     Businesses reject mismatches as recoverable outcomes rather than
     silently converting quantities.
   - Business responses echo `quantity_unit` for every non-`each` line.
   - Order, fulfillment, and adjustment quantities inherit the line's
     sale basis, preserving exact integer status and return arithmetic.

   Quote `price` per one whole `quantity_unit.unit`, not per integer step,
   because per-step prices can require fractional currency minor units.
   Businesses compute `price × quantity × 10^-scale`, round once at the
   line, and return authoritative totals.

   For example, fasteners priced at 1299 USD per kg with `scale: 2` and
   `quantity: 150` represent 1.50 kg. The line total is 1948.5 minor units,
   rounded once to 1949. A fulfillment quantity of 50 represents 0.50 kg,
   and an adjustment of -25 represents a 0.25 kg return.

   Keep `unit_price` separate as a display comparator and harmonize its
   `measure` and `reference` with the shared descriptor. Their values must
   be positive integers, their units must match, and
   `unit_price.currency` must equal `price.currency`; no unit or currency
   conversion occurs in the comparator.

   This is backward compatible for payloads that do not use `unit_price`:
   `quantity` remains an integer, `quantity_unit` is optional, and omission
   retains the existing `each` wire representation.

   This is breaking for existing `unit_price` producers. `measure` and
   `reference` must migrate from decimal/free-form measurements to integer
   values with required `unit` and `display_text` descriptors. Zero and
   negative comparator measures are now invalid, and same-unit and
   same-currency equality are normative invariants.
   The existing wording could be read as making (`unit`, effective `scale`)
   the complete identity of a purchasable sale unit. That conflates the
   denomination used for quantity arithmetic with characteristics that may
   configure or price the item.

   Define the pair as the unit descriptor's machine identity only. Keep catalog
   variant identity separate, clarify that other sale-unit characteristics may
   affect the Business-quoted price without changing its denominator, and state
   that Order lifecycle quantity arithmetic uses only inherited sale-basis steps.

   This is a description-only clarification. It adds no fields or measurement
   configuration behavior, preserving the current wire contract while leaving
   that model to a separately negotiated extension.
@igrigorik
igrigorik requested a review from jingyli July 30, 2026 21:12
@igrigorik igrigorik self-assigned this Jul 30, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Jul 30, 2026
@amithanda

Copy link
Copy Markdown
Contributor

Thanks for putting this together, @igrigorik! This PR seems heavily aligned with one of the design options we discussed in #597, and it completely solves the arithmetic coupling and nesting problems of an anyOf quantity object.

1. What I think is aligned with the #597 design option:

  • Single Unit Declaration on item / variant: Instead of embedding { value, unit } inside every quantity field, the selling unit is declared once on item.quantity_unit (and variant.quantity_unit).
  • Inheritance Across the Order Lifecycle: All dependent records (order_line_item.quantity.{original,total,fulfilled}, fulfillment_event, adjustment) carry plain scalar quantities and inherit the line item's unit. Two records on the same line can never disagree about units.
  • Unit Pricing (item.price): price is always quoted per whole selling unit (e.g., per 1 kg or per 1 lb), not per step.
  • Defaulting to each: An absent descriptor cleanly defaults to countable items (each), requiring zero migration for existing countable-only producers.

2. The One Key Difference: Approach A vs. Approach B

Where this PR diverges from that proposal is in how numeric quantities and step granularity are represented on the wire:

  • PR# 597 Proposal (Approach A: Decimal + Arbitrary Step): quantity is a floating-point number (1.5), and the unit descriptor declares an explicit decimal increment via step: 0.25.
  • This PR (Approach B: Integer Step Count + Scale): quantity is an integer step count (150), and the unit descriptor declares a power-of-ten scale: 2 (10^-2, where 1 step = 0.01).

3. I am leaning towards Approach A (number + step), here's why:

While integer minor-unit math (scale: 2150) works beautifully for currency amounts (price.amount), applying that same pattern to physical quantities introduces significant developer-experience risks and UI limitations that Approach A avoids.

A. The "Scale Misread Hazard" vs. WYSIWYG Readability

In e-commerce, physical quantities are natively decimal (1.5 lb, 0.75 kg, 2.5 yd).

// Approach A: WYSIWYG (What You See Is What You Get)
{
  "item": { "id": "sku_apples", "sold_by": { "unit": "lb", "step": 0.25 } },
  "quantity": 1.5
}

// Approach B: Indirected (Requires mental/programmatic division by 10^scale)
{
  "item": { "id": "sku_apples", "quantity_unit": { "unit": "LBR", "scale": 2, "display_text": "lb" } },
  "quantity": 150
}
  • In Approach A (number): Any frontend developer, backend service, or LLM shopping agent reading "quantity": 1.5 immediately understands 1.5 lb without inspecting secondary fields.
  • In Approach B (integer + scale): A payload of "quantity": 150 is indirected. The consumer must read quantity_unit.scale: 2 and divide by 10^scale. If an API client, analytics script, or LLM agent misses scale or defaults it to 0, the result is a 100x order error (150 lb instead of 1.50 lb).

B. The step: 0.25: Declarative UI vs. Runtime Rejection

Real-world merchants rarely sell measured goods in arbitrary power-of-ten increments (0.01). They sell according to discrete fractional rules (e.g., produce in 0.25 lb increments, fabric in 0.5 yd increments, or services in 0.25 hr / 15-minute blocks).

  • With Approach A (step: 0.25): The Platform UI receives an explicit declarative constraint. The cart can render +0.25 / -0.25 Stepper buttons (1.0, 1.25, 1.50) and instantly validate client-side before sending an API request.
  • With Approach B (scale: 2): The Platform only knows precision goes to 0.01. If a shopper types 1.37 lb, the Platform sends 137. Because power-of-ten scale cannot express quarter-pound rules, the Business is forced to reject the checkout with a runtime error ("Apples must be ordered in 0.25 lb increments"), degrading the shopper experience.

C. The Root Cause: Why fulfilled == total is Broken for Weighed Goods in BOTH Approaches

One main advantage justification for integer step counts (Approach B) is avoiding IEEE 754 binary floating-point accumulation bugs (0.1 + 0.2 === 0.30000000000000004) when deriving order completion:
$$\text{status} = \text{"fulfilled"} \iff \text{quantity.fulfilled} == \text{quantity.total}$$

However, in the physical world of grocery and catch-weight retail, the fulfilled weight almost NEVER equals the ordered weight.

  • A shopper orders 1.50 lb of apples (quantity.original = 1.5).
  • A picker weighs three apples at the store, and they come out to 1.48 lb (or 1.53 lb).
  • Because 1.48 != 1.50 (or in Approach B, 148 != 150), relying on mathematical equality (fulfilled == total) to determine line item completion is fundamentally broken for measured goods in BOTH Approach A and Approach B.

In real-world commerce, completion is not derived from equality; it is authoritatively declared by the Business. We should address the root cause by making two simple adjustments to our specification prose:

  1. Business-Authoritative Line Item Status:
    Update order_line_item.status prose so Platforms do not derive completion mathematically for measured goods:

    "Authoritative Line Item Status: order_line_item.status is authoritatively determined by the Business. For countable goods (each), a line item is typically "fulfilled" when quantity.fulfilled == quantity.total. For measured or weighed goods, the fulfilled quantity may legitimately differ from the ordered quantity (e.g., fulfilling 1.48 lb for a 1.50 lb order); the Business authoritatively sets status: "fulfilled" when the line item is satisfied according to its fulfillment rules."

  2. Canonical Quantity Equality (to prevent IEEE 754 float drift):
    Where quantity equality is evaluated, define a clean rounding invariant:

    "Quantity Equality and Status Derivation: When evaluating quantity equality or comparing floating-point quantity values, implementations MUST round values to 4 decimal places before comparison to prevent IEEE 754 binary floating-point representation errors. When an item declares sold_by.step, implementations MAY evaluate equality modulo that step: two quantities are equal if their absolute difference is less than half the declared step."

   Add common/types/quantity_unit.json — the shared unit descriptor
   composed (allOf) with an optional integer `increment`, a count of
   scale-steps — and remount variant/item quantity_unit on it. The bare
   descriptor family is unchanged: measure.json still composes unit.json,
   so increment cannot appear on unit_price internals or future measures.

   Increment is advisory merchandising policy, not a representational
   bound. `scale` bounds what any quantity can express; `increment`
   shapes what the Platform asks for: Platform-authored quantities
   SHOULD be increment multiples, and the Business accepts, visibly
   revises (never silently reinterprets), or rejects an off-increment
   ask through the standard recoverable-message channel. The Business
   MAY also revise quantities for its own reasons (e.g. limited stock),
   keeping revisions on-grid so stepper edits from the revised value
   stay valid. Business-recorded facts — fulfillment events, adjustments
   — are bounded only by scale, keeping catch-weight reality
   representable. Increment is excluded from unit-descriptor machine
   identity and mismatch comparison.

   overview.md carries the shared contract, checkout.md the normative
   behavior plus a snap example, cart.md delegates, order.md scopes
   recorded facts to scale.
   Add an optional integer `increment` to the sale-basis descriptor
   (common/types/quantity_unit.json, composing the shared unit descriptor
   via allOf; variant/item remount onto it). Increment is a count of
   scale-steps and is advisory merchandising policy: it bounds what the
   Platform asks for, while scale bounds what any quantity can express.
   Platform-authored quantities SHOULD be increment multiples; the
   Business accepts, visibly revises, or rejects off-increment asks; and
   Business-recorded facts (fulfillment events, adjustments, revisions)
   are bounded only by scale. Increment is excluded from machine identity
   and never appears on measure/unit_price internals.

   Define the negotiation model around the descriptor:

   - Discovery: the Platform SHOULD learn the sale basis (unit, scale,
     increment) from the catalog; without it, omit the descriptor and
     read the authoritative basis from the response echo. Assertion
     verifies a previously discovered basis - omit rather than guess.
   - Mismatch: silent conversion remains forbidden. The Business MAY
     convert an asserted basis to its authoritative basis as a visible
     line revision with a warning, else MUST reject recoverably (update:
     line unchanged; create: line not created). UCP defines no
     conversion factors or dimensions; whether to convert is the
     Business's own determination.
   - Catch-weight: picked-vs-ordered variance reconciles through
     adjustments that move money together with quantity; fulfilled ==
     total then holds exactly, and no rounding tolerances or epsilon
     comparisons exist anywhere in the quantity lifecycle.

   Docs: overview leads with the no-floating-point rationale (quantity
   arithmetic feeds money) and the zero-arithmetic rendering recipe
   (shift by scale, append display_text); checkout shows one mismatched
   update answered two ways - conversion vs rejection - so the
   machine-readable recovery path (the echoed descriptor, never message
   content) is visible in the JSON; order adds a catch-weight worked
   example; catalog advertises the increment as part of discovery. All
   worked examples follow a single SKU (fasteners, KGM/scale 2,
   increment 25) end to end; pounds appear only as the stale wrong
   assertion in the mismatch exhibit.
   Recast the sale-basis examples around the predominant use case: one
   grocery SKU — bananas at LBR/scale 2/increment 25, $0.79/lb — now
   runs end to end through catalog advertisement, checkout pricing,
   increment snap, partial fulfillment with return, and catch-weight
   reconciliation.

   The unit-conversion exhibit keeps an industrial cameo (fasteners sold
   by the kilogram, no increment), which both shows the model beyond
   grocery and keeps the mismatch pair free of increment interplay. A
   catalog note adds that metered offerings (MIN, HUR) ride the same
   contract, and overview snippets align to the lead (0.25 lb increment
   illustration; 1.90 lb pick against a 2.00 lb order).
@igrigorik

Copy link
Copy Markdown
Contributor Author

Thanks @amithanda and @jingli, great flags and points. PTAL at the latest commits.

Declarative ordering granularity: landed as increment on the sale-basis descriptor — an integer count of scale-steps, so { "unit": "LBR", "scale": 2, "increment": 25 } reads "record to 0.01 lb, order in quarter-pound multiples." Platforms get steppers and client-side validation with exact integer math (no float multipleOf/modulo issues). It's deliberately advisory: increment bounds the ask, scale bounds the fact — exactly what catch-weight needs: ordering happens in 0.25 lb steps, and a 1.90 lb pick is still recordable.

Catch-weight: fully agree that fulfilled != ordered is normal, not exceptional. Order now shows the flow: the Business records the actual pick and reconciles with an adjustment that moves money together with quantity — after which fulfilled == total holds exactly. Completion stays business-authoritative through the records the Business writes, without decoupling status from quantity/money coherence.

First-call churn and error recovery: checkout now defines discovery explicitly. Platform learns the basis from the catalog, or omits the descriptor and reads the authoritative basis from the response echo — no error roundtrip; asserting a basis is opt-in verification of something previously discovered, not a forced guess. Mismatch handling also widened: the Business MAY convert an asserted basis to its authoritative basis as a visible line revision with a warning (one roundtrip for the cold-start case), or reject with a recoverable error.

Floats vs integers is still the spicy part...

There are two different problems hiding inside "quantity equality," and they need different tools:

  1. Representation error — float artifacts. Three picks accumulate to 1.4999999999999998 against a total of 1.5: logically equal, bitwise unequal.
  2. Commercial variance — physical reality. A 1.48 lb pick against a 1.50 lb order: genuinely different, by two real ounces of product.

Remedy 2 proposes one rule for each, but run the catch-weight example through them and they contradict each other. Rounding to 4 decimals only erases noise below 0.0001, so it correctly preserves the real difference: 1.48 != 1.50, and the line stays partial forever. Half-step tolerance goes the other way: |1.50 − 1.48| = 0.02 < 0.125, so the quantities are declared equal and the line completes — but because they're now "equal," nothing triggers a price reconciliation. The buyer pays for 1.50 lb and receives 1.48.

The integer wire dissolves problem 1 outright: step counts compare exactly in every language, so the spec carries no rounding or tolerance text anywhere. That leaves problem 2 to be handled as what it is — a commercial fact, not a numeric one: the Business records the actual pick and reconciles with an adjustment that moves money together with quantity, after which fulfilled == total holds exactly. It's the same reasoning that puts money in minor units, and the overview now states it directly... Under integers, no tolerance or epsilon text exists anywhere in the lifecycle; variance is a commercial fact settled through adjustments that keep money and quantity in sync.

Re, implementation burden

A fair concession first: unlike ISO 4217, scale is per-item data with no static table — which is precisely why the echo is a MUST: every authoritative line carries its own denominator, so interpretation never depends on an upfront catalog call. Beyond that, the cost is smaller than it reads:

  • Countable goods never see any of this. No descriptor, wire byte-identical to today.
  • Reading is not arithmetic. Shift the decimal point scale places, append display_text: 150 + { "scale": 2, "display_text": "kg" } → "1.50 kg" — the same code path for a Rec20 code and a unit you've never heard of.
  • You pay per policy used. increment defaults to 1; only businesses with coarser selling granularity declare it.

The examples now follow one grocery SKU end to end — catalog → pricing → increment snap → partial fulfillment with return → catch-weight reconciliation — plus a non-grocery conversion cameo.

@gsmith85 gsmith85 self-assigned this Jul 31, 2026
@gsmith85

gsmith85 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Thanks @igrigorik, @jingyli, and @amithanda for the thoughtful discussion across PR #597 and PR #653.

Looking across both proposals, there appears to be a path to achieve the core requirements into a single, unified Measure datatype. By encapsulating integer scaling within Measure, we can address the key design goals raised in both threads:

  1. Standardized Unit Vocabulary: Uses UN/CEFACT Rec 20 ID fragments (pound, kilogram, ounce, gram, fluid_ounce_(US)), facilitating cross-merchant price comparisons and agent reasoning directly. Following UCP's pseudo-enum pattern (as used in total.json and fulfillment_method.json), we enumerate well-known values inline in the schema prose while allowing businesses to use additional valid UNECE Rec 20 ID fragments.
  2. Floating-Point Precision Safety: Uses an exact integer value and step counts (value: 150, scale: 2 for 1.50) for measured goods.
  3. Concise Countable Representation: Standard countable goods (the primary e-commerce case) remain simple, un-nested primitive integers ("quantity": 2) without requiring object wrappers.
  4. Backwards Compatibility: Existing integrations sending "quantity": 2 remain valid, avoiding breaking changes.

Proposed Measure Schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/common/types/measure.json",
  "title": "Measure",
  "description": "Represents a count or quantitative measurement.",
  "oneOf": [
    { "type": "integer", "description": "Countable item quantity. Semantically equivalent to an object with `unit: \"each\"`."},
    {
      "type": "object",
      "required": ["value", "unit"],
      "additionalProperties": true,
      "properties": {
        "value": { "type": "integer", "description": "Quantitative value as an integer step count." },
        "scale": { "type": "integer", "default": 0, "description": "Exponent denominator (10^-scale)." },
        "unit": { "type": "string", "description": "UNECE Rec 20 ID fragment. Well-known values: `each`, `pound`, `kilogram`, `ounce`, `gram`, `millilitre`, `fluid_ounce_(US)`. Businesses MAY use additional valid UNECE Rec 20 ID fragments." },
        "increment": { "type": "integer", "description": "Advisory ordering step multiple sharing the same scale." }
      }
    }
  ]
}

Payloads in Practice:

  • Countable goods: "quantity": 2
  • Weighed goods (1.50 lbs of apples):
    "quantity": {
      "value": 150,
      "scale": 2,
      "unit": "pound"
    }
  • Weighed goods with ordering step (0.25 lb increments):
    "quantity": {
      "value": 150,
      "scale": 2,
      "unit": "pound",
      "increment": 25
    }

This follows existing UCP schema conventions (such as fulfillment_destination.json) by encapsulating the polymorphic logic within the shared type, keeping line_item, expectation, fulfillment_event, and adjustment clean.

@amithanda

amithanda commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

On the "spicy part" (Floats vs. Integers) - I will propose to discuss a bit more on what to optimize for. I am still leaning more towards optimizing for simplicity and I think agent friendly WYSIWYG decimal number (1.5, step: 0.25) is a safer and more ecosystem-friendly contract than an indirected integer step count (150, scale: 2):

1. 4-Decimal Rounding ($10^{-4}$) NEVER Swallows Catch-Weight Variance

  • Proposing 4-decimal rounding ($10^{-4}$) is strictly an engineering guardrail for Problem 1: IEEE 754 binary floating-point noise (1.4999999999999998 == 1.5000).
  • Because 1.48 lb picked and 1.50 lb ordered differ by 0.02 ($2 \times 10^{-2}$), they are never equal under 4-decimal rounding (1.4800 != 1.5000). 4-decimal rounding never swallows commercial variance; catch-weight is a commercial event solved by a commercial record.

2. Catch-Weight: Commercial Remedy Works Identically in Floats

  • In the order example (docs/specification/order.md), the catch-weight line completed not because we used integers , but because the Business emitted an adjustment record that lowered quantity.total from 200 to 190 to match quantity.fulfilled (190 == 190).
  • When the Business lowers quantity.total from 2.00 to 1.90 in floats, 1.90 == 1.90 evaluates to true in floating-point math just as cleanly as 190 == 190 holds in integers. Both models rely on the exact same adjustment record to achieve fulfilled == total.

3. The "Scale Misread Hazard": Dynamic Denominators are 100x Worse than Float Noise

  • For currency (USD), scale is globally fixed and static by ISO 4217 (always 2).
  • In Approach B, quantity scale is dynamic per item and defaults to 0. If an API client, ERP script, or LLM shopping agent reads "quantity": 150 and misses scale: 2, it interprets 1.50 lb as 150 lb—a 100x order error. In floats, 1.5 is WYSIWYG everywhere.

4. Consider implications on SQL and AI Agents

  • SQL / BI Reporting: SELECT SUM(quantity) returns complete gibberish across mixed scales (150, 15, 1500) unless every SQL query is wrapped in dynamic quantity * POW(10, -scale) math. In floats, SUM(quantity) is natively correct (1.5 + 1.5 = 3.0 lb).
  • LLM / AI Agents: Autonomous shopping agents reason over WYSIWYG numbers (1.5) zero-shot. Forcing an LLM to inspect a secondary nested property (scale: 2) and divide before reasoning over cart quantities increases hallucination rates.

@amithanda

Copy link
Copy Markdown
Contributor

Thanks @gsmith85! Your first point—using UN/CEFACT Rec 20 ID fragments ("pound", "kilogram", "each", "millilitre") with inline well-known values in the schema prose—is great for readability for both human developers and LLM shopping agents, totally supportive of this.

Compared to opaque Rec 20 Common Codes ("LBR", "C62"), using readable ID fragments ("pound", "each") with an open tail prevents synonym drift (lb vs lbs vs pound) while remaining self-documenting.

Where I would caution against adopting a polymorphic oneOf [integer, object] for quantity is that **embedding { value, scale, unit, increment } inside every quantity field reverses one of the design point in the current PR and what we proposed on PR#597 **

Inheritance v/s Embedding Across the Order Lifecycle

In PR #597, placing the unit inside quantity created severe arithmetic coupling across the order lifecycle. That is why @igrigorik moved the descriptor to be declared once on item / variant in PR #653, so that all dependent records (order_line_item.quantity.{original,total,fulfilled}, fulfillment_event, adjustment) carry clean scalar numbers and inherit the line item's unit.
If we wrap quantity in a polymorphic Measure object across the lifecycle:

  1. Re-introduces Mismatch Hazards: Two records on the same line item could arrive with different scales ({"value": 15, "scale": 1} vs {"value": 150, "scale": 2}) or different units ("pound" vs "ounce"), breaking simple numeric comparisons (fulfilled == total).
  2. Payload Bloat: Every micro-fulfillment event and line item adjustment must repeat { "value": 150, "scale": 2, "unit": "pound", "increment": 25 } instead of sending a clean scalar.
  3. Fails on Variable-Weight Countables (@alex-jansen’s use case): A single polymorphic Measure object cannot express ordering 3 Honeycrisp apples (count: 3) priced at $1.99/lb with a 0.4 lb nominal size without conflating apple count with apple weight.

Also, see my comment (float v/s integer above) on leaning towards a more simplified representation which is easier for agents to read and understand and reduces chances of errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants