Find every usage level where a tiered price table charges less for more, in exact arithmetic.
Usage-based price tables combine volume tiers, graduated tiers, flat fees, free allowances, package rounding, minimums, spend caps and per-line invoice rounding. Put together, they often make the total go down as usage goes up: a customer at 10,000 API calls pays 1,000.00 while one at 10,001 pays 800.08, so anyone between 8,001 and 10,000 calls should buy more than they need. Pricing teams find these by spot-checking a spreadsheet, which misses the boundaries nobody thought to test, and billing platforms validate the schema of a price table, not the function it defines. Scanning every quantity is exact but does not scale: a table reaching 10^12 units would take days to scan.
tiercliff reads a plan (TOML or JSON), compiles it into a sorted list of pieces, and analyses the pieces instead of the quantities.
-
Compilation. Every structural boundary is collected: the end of the free allowance, each tier bound (scaled by package size and shifted by the allowance) and the usage cap. Between two boundaries the billed tier is fixed, so the cost has the form
cost(q) = base + slope * ceil((q - origin) / period)with
slope >= 0.periodis 1 for per-unit pricing and the package size for package pricing, which makes the piece a staircase. Each raw piece is then split where the minimum starts to bind and where the maximum is reached; those split points are solved for exactly, not searched. -
The invariant. Validation rejects negative prices and fees, so the cost is nondecreasing inside every piece. A decrease can only happen between the last quantity of one piece and the first of the next. Checking
cost(hi + 1) < cost(hi)at each piece boundary therefore finds every drop in 0..=10^12, and the work is proportional to the number of tiers, not the range. -
Dominated range. For each run of drops, tiercliff walks back through the pieces and solves
base + slope * steps(q) > landingfor the smallestq. Every quantity from there to the cliff costs strictly more than buying one unit past it. -
Invoice rounding. The rounded invoice (whole total or per line, in half-up, half-even, up or down) is nondecreasing inside each piece as well, so the same boundaries apply; the dominated range is found by binary search instead of algebra. The report lists drops that rounding introduces and drops that rounding hides.
-
Exact numbers. All amounts are rationals over
i128with checked arithmetic. Decimal literals like"0.10"are parsed straight to1/10and never pass through a float. Plan validation bounds quantities (10^12), unit prices (10^6), amounts (10^15) and the common denominator (10^9) so no intermediate value can overflow. -
Comparison.
comparesweeps all plans' pieces together. On each block where every plan is affine, the cheapest set can only change at the integers either side of a pairwise crossing, which is computed exactly. The output is the exact lower envelope: which plan is cheapest (or tied) for every quantity in the range.
Worked example, plans/volume.toml (0.10 up to 10,000 units, 0.08 up to
100,000, then 0.05 plus a 500 fee). The compiler produces four pieces:
[0,0], [1,10000] slope 0.10, [10001,100000] slope 0.08, and
[100001,10^12] slope 0.05 base 500. At the first boundary,
cost(10,000) = 1000 and cost(10,001) = 800.08, a drop. Solving
0.10 q > 800.08 gives q >= 8,001, so 8,001 to 10,000 units are all
dominated by buying 10,001.
Requires Rust 1.85 or newer. From a checkout of this repository:
cargo install --path .
or run it without installing via cargo run --release -- <args>.
tiercliff check <plan.toml|plan.json> [--json]
tiercliff compare <plan> <plan> [<plan>...] [--up-to N] [--json]
tiercliff eval <plan> <quantity>...
check exits 0 when neither the exact table nor the invoice ever decreases,
1 when cliffs exist, and 2 on invalid input, so it can gate a pricing change
in CI.
name = "api-volume" # optional, defaults to the file name
currency = "USD" # optional, label only
scheme = "volume" # "volume" or "graduated" (required)
minor_units = 2 # decimals on the invoice, 0-4
included = 0 # free units deducted before billing
package_size = 1 # units per billed package, rounded up
minimum = 0 # the charge is never below this
maximum = 5000 # optional: the charge is never above this
unit_cap = 1000000 # optional: usage above this is not billed
rounding = "total" # "total" or "per_line"
rounding_mode = "half_up" # "half_up", "half_even", "up", "down"
[[tiers]]
up_to = 10000 # inclusive, in billed units; omit on the last tier
unit_price = "0.10" # quote decimals or write them bare; "1/3" also works
flat_fee = 0
JSON uses the same keys ("tiers": [{...}]). Unknown keys are rejected so a
typo cannot silently change a price.
$ tiercliff check plans/volume.toml
api-volume: volume pricing, 3 tiers, USD
compiled into 4 pieces
price table: 2 cliffs
10,000 -> 10,001 units: cost falls from 1000.00 to 800.08 (saves 199.92)
8,001 to 10,000 units (2,000 quantities) all cost more than buying 10,001
100,000 -> 100,001 units: cost falls from 8000.00 to 5500.05 (saves 2499.95)
68,751 to 100,000 units (31,250 quantities) all cost more than buying 100,001
invoice rounding (total, half_up, 2 decimals):
same cliffs as the price table
$ echo $?
1
$ tiercliff check plans/graduated.toml
storage-graduated: graduated pricing, 3 tiers, USD
compiled into 6 pieces
price table: no cliffs, cost never decreases
invoice rounding (total, half_up, 2 decimals):
same cliffs as the price table
cost stops growing at 226,000 units (5000.00)
Per-line rounding: 3 units bill one line of 1.005, rounded to 1.01. 4 units bill 1.004 and a 0.004 fee, exactly 1.008, but each line rounds down, so the invoice says 1.00.
$ tiercliff check plans/per-line.toml
egress-per-line: volume pricing, 2 tiers, USD
compiled into 3 pieces
price table: no cliffs, cost never decreases
invoice rounding (per_line, half_up, 2 decimals):
rounding introduces drops after: 3
3 -> 4 units: cost falls from 1.01 to 1.00 (saves 0.01)
3 units costs more than buying 4
--json prints the same report as one JSON object. Amounts are strings
holding exact decimals (or n/d when the value has no finite decimal form):
$ tiercliff check plans/per-line.toml --json
{"plan":"egress-per-line","currency":"USD","pieces":3,"cliffs":[],"invoice_cliffs":[{"start":3,"end":3,"buy_up_to":4,"cost_before":"1.01","cost_after":"1","saving":"0.01","dominated_from":3}],"introduced_by_rounding":[3],"hidden_by_rounding":[],"plateau":null}
$ tiercliff compare plans/starter.json plans/growth.toml
A = starter
B = growth
cheapest plan for 0..100,000 units:
0..25,499 A (at 0: A 0.00, B 49.00)
25,500 A = B (at 25,500: A 49.00, B 49.00)
25,501..100,000 B (at 25,501: A 49.002, B 49.00)
crossovers at: 25,500, 25,501
Without --up-to, the range is twice the last structural boundary of any
plan (at least 10,000), so every feature of every plan is covered.
$ tiercliff eval plans/volume.toml 8000 8001 10000 10001
8,000 exact 800 invoice 800.00
8,001 exact 800.1 invoice 800.10
10,000 exact 1000 invoice 1000.00
10,001 exact 800.08 invoice 800.08
use tiercliff::{check, Plan};
let plan = Plan::from_toml(r#"
scheme = "volume"
[[tiers]]
up_to = 10000
unit_price = "0.10"
[[tiers]]
unit_price = "0.08"
"#, "example").unwrap();
let report = check(&plan);
assert_eq!(report.cliffs[0].dominated_from, 8_001);
The claim is that check finds exactly the drops a full scan would find.
tests/oracle.rs tests that claim against a brute-force oracle, which
re-implements pricing and rounding from the plan fields without calling the
library's evaluator. Random plans mix both schemes, 1-5 tiers, allowances,
packages, minimums, maximums, usage caps, every rounding mode and 0-3 decimal
places, with prices having denominators 1, 3, 4, 100 and 1000. For each plan
the oracle evaluates every quantity up to the last boundary plus a margin and
asserts:
- every piece value and
Plan::costequal the oracle's cost; - the reported exact and invoice drop points are exactly the oracle's;
- every
cost_before,cost_after,savinganddominated_frommatches; - the drops introduced and hidden by rounding match, and total rounding introduces none;
- the plateau really is flat from its start and not before it.
256 random plans run with small tiers, 6 run with tiers of up to 40,000 units
(around 10^5 quantities each), and 64 random two- or three-plan comparisons
check the cheapest set at every quantity. tests/cli.rs checks the binary's
output, exit codes and error messages, with the expected numbers worked out
by hand in the comments.
cargo test
cargo run --release --example bench
Generated tables with evenly spaced tiers across 0..=10^12, random prices, occasional flat fees and a minimum of 5. Median of 11 runs, measured on an Apple Silicon Mac (aarch64-apple-darwin), rustc 1.94, release build:
| scheme | tiers | pieces | cliffs | check time |
|---|---|---|---|---|
| volume | 10 | 11 | 5 | 0.651 ms |
| volume | 100 | 101 | 50 | 6.328 ms |
| volume | 1000 | 1002 | 510 | 118.680 ms |
| graduated | 10 | 12 | 0 | 0.047 ms |
| graduated | 100 | 102 | 0 | 3.717 ms |
| graduated | 1000 | 1001 | 0 | 377.269 ms |
For contrast, scanning a 1,000-tier volume table one quantity at a time with
the same exact evaluator ran 10^7 quantities in 3.13 s (3.2 million per
second, with 0 drops in that prefix). At that rate the full 10^12 range
takes about 87 hours. check
covers the whole range in 0.12 s. Both schemes grow faster than linearly in
the tier count: the invoice at each boundary is computed by the direct
evaluator, which finds the tier by linear scan (volume) or sums every lower
tier (graduated), and the graduated compiler does the same summation per
piece.
Pieces with a proven monotone interior, rather than sampling or symbolic algebra. The core decision is to reject negative prices and fees at validation time. That one rule makes the cost nondecreasing inside any range where the tier, the allowance and the cap are fixed. Every source of a drop (a volume tier switch, a flat fee that changes, a minimum that stops binding) must then sit on a boundary, and the set of boundaries is small and known ahead of time. This makes the search complete: it is not "we checked a lot of points" but "no other point can drop". The cost is expressiveness. Credits, discounts as negative lines, and prices that fall as a formula of usage within a tier cannot be modelled, because they would break the invariant that the proof rests on.
Exact rationals over i128 with hard input limits, rather than a bignum
library or decimals. Billing bugs of one cent are exactly what this tool is
for, so floats are out. Fixed-scale decimals cannot represent 1/3 per unit
or the exact crossover point of two plans. A bignum crate would remove the
limits but costs allocation on every operation and adds a dependency. The
crate has no runtime dependencies at all; its TOML and JSON readers are small
hand-written parsers for the subset plans need. The trade-off is that
validation must bound quantities, amounts and the common denominator so that
the largest intermediate product fits in 127 bits, and a plan outside those
bounds is rejected rather than analysed. Arithmetic is still checked, so a
mistake in those bounds panics instead of printing a wrong price.
- Pricing models covered: volume and graduated tiers with per-unit prices and per-tier flat fees, one free allowance, one package size, a minimum, a maximum and a usage cap. Not covered: negative amounts (credits, discounts), per-tier package sizes, time-based proration, multiple metered dimensions priced together, and currencies or taxes applied after rounding.
- Quantities are whole units from 0 to 10^12. Fractional usage (for example GB-hours billed to three decimals) must be rescaled to integer units first.
- Unit prices are capped at 10^6, amounts at 10^15, and all denominators in a plan (or across compared plans) must share a common multiple of at most 10^9. Plans outside those bounds are rejected.
compareis exact but visits each package step separately when a plan uses package rounding, and refuses ranges with more than 20 million steps; pass a smaller--up-tofor those.- The graduated compiler recomputes the fixed charge of all lower tiers for each piece, so compile time grows with the square of the tier count. It stays well under a second at 1,000 tiers.
- The TOML reader handles the subset plans use (key/value pairs, strings,
numbers,
[[tiers]]arrays, comments), not the full TOML specification.
MIT. See LICENSE.