Package tiers prices one amount against a graduated schedule: cut the amount at caller-declared breakpoints, charge each slice at its own rate, and sum.
This is the arithmetic behind a progressive fee, a marginal tax bracket, a volume discount and a tiered royalty. Nothing here reads the chain, holds state, or knows what the amount is a quantity of, so a realm can price a whole schedule without a node.
Example
1s, _ := New([]int64{1000, 5000}, []int64{100, 250, 500})
2parts, err := s.SliceAt(7000) // [1000 4000 2000] -- the partition
3col, err := s.ChargeByTier(7000) // [10 100 100] -- the line items
4fee, err := s.Charge(7000) // 210 -- their sum
The three steps are separable on purpose, in the shape p/aging uses: the partition is what a reader checks against the schedule, the per-tier column is the line items, and Schedule.Charge is the one number that goes in a ledger. Charge is *defined* as the sum of the column, so the two cannot disagree.
Marginal, and never a cliff
Each slice of the amount is charged at the rate for the tier it falls in. The whole amount is never charged at the top tier's rate. Those are different numbers, and the difference is not a rounding matter: the cliff form has a discontinuity at every edge, where earning one more unit can cost more than one unit.
With edges `{100}` and rates `{0, 5000}`, the cliff form charges 0 at 99 and 50 at 100 — one extra unit costs fifty. The graduated form charges 0 at 100 and 0 at 101, because only the one unit above the edge is priced at 50%.
What this package guarantees, at every amount and for every valid schedule:
Example
1Charge(n) <= Charge(n+1) <= Charge(n) + 1
The left inequality is monotonicity; the right is the no-cliff property. Adding one unit to the amount grows exactly one slice by one, and `floor((x+1)r/RateScale) - floor(x·r/RateScale)` is 0 or 1 whenever `r <= RateScale`. [TestChargeHasNoCliffAtAnyEdge] asserts it at and around every edge of every schedule it builds, and [TestCliffFormulaViolatesIt] pins the counterexample so that a later "simplification" to the cliff form fails the suite instead of silently changing every published number.
A third consequence of the same bound: `Charge(n) <= n`. A graduated schedule can never charge more than the amount it is pricing, which is what makes the rate cap in New an arithmetic fact and not only hygiene.
The amount is a length, and the half-open convention is load-bearing
Think of the amount as the interval `[0, amount)`. Tier i covers the half-open range `[edges[i-1], edges[i])`, with tier 0 starting at 0 and the last tier unbounded above. That makes the tiers exhaustive and disjoint over every non-negative amount, with no breakpoint shared by two tiers. A slice is the overlap of `[0, amount)` with a tier's range, so the slices always sum to exactly the amount — Schedule.SliceAt's cross-foot.
The unit running from x to x+1 is priced at the tier containing **x**. So Schedule.TierOf and Schedule.MarginalRate answer a question about the *next* unit, not the last one:
Example
1MarginalRate(n) is the rate the (n+1)'th unit will attract.
At an edge the two readings differ, and that difference is the off-by-one this package is most exposed to. With edges `{30}`, `MarginalRate(30)` is the *upper* tier's rate: the amount 30 exactly fills tier 0, and the next unit is the first one above the edge. The other reading — "the rate the last unit was charged at" — would answer with the lower tier, and is not offered, because it is undefined at amount 0.
p/aging shipped an off-by-one of exactly this kind in its own discovery record (its doc, "The degenerate two-bucket case"), so the convention is stated here and pinned by [TestMarginalRateAtAnEdgeIsTheUpperTier] rather than left to a reader to reconstruct. Schedule.TierRange exists for the same reason: reconstructing a half-open range from Schedule.Edges is where the mistake lives.
Why the floor is taken per slice
Example
1Charge = Σ floor(sliceᵢ × rateᵢ / RateScale)
not one floor taken at the end of an exact rational total. Those are different numbers, and which one a package shipped is invisible in any test that happens to use round rates.
Per-slice is chosen because Schedule.ChargeByTier is the line-item column a reader sees next to the slices, and line items that do not add up to the stated total are a bookkeeping defect regardless of which total is "more accurate". The same discipline, and the same rationale, as p/aging's per-bucket floor and p/depreciation's `Charge`-from-`CarryingAt`.
[TestFloorIsPerSliceNotAtTheEnd] pins a case where the two disagree.
Monotone in the amount — and why, unlike [p/aging], it really is
p/aging published the analogous claim as settled and the code falsified it: its provision can **fall** as time advances, by up to one unit per bucket, because mass *moves between* buckets as an obligation ages and each bucket re-floors. Its doc records the counterexample and the attained bound.
Here the claim holds, and the reason is structural rather than fortunate: increasing the amount only ever **adds mass at the top**. Every lower slice is already full, so its subtotal is literally the same number and no floored subtotal can shrink. Nothing moves between slices.
That is an argument, so it is also measured: [TestChargeIsMonotone] sweeps random schedules across contiguous amounts and asserts a strictly non-decreasing charge. The discovery record for this package committed to amending itself rather than quietly editing if the sweep found a drop; the sweep found none, and the figure it ran is in the test rather than restated here.
Why the rates need NOT be monotone, which is the opposite of [p/aging]
New accepts rates in any order. A rising schedule is a progressive fee; a falling one is a volume discount, which is the same arithmetic read from the other end and is a first-class use of this package, not a tolerated degenerate case.
p/aging refuses a falling schedule, and the contrast is worth stating because copying that check here would be the plausible mistake. It needs the ordering for two reasons, neither of which transfers: a falling loss rate is a domain error (an older receivable is not more collectible), and without the ordering its exact-rational monotonicity fails as well, since an obligation crossing into a cheaper bucket *reduces* the total. In this package nothing crosses into anything. The monotonicity above needs only `rate >= 0`.
So a transposed rate array is a silent authoring error here in a way it is not in p/aging, and this package cannot detect it. That is recorded in "What this package does not claim" rather than papered over with a check that would also forbid every discount schedule.
The per-slice multiply goes through 128 bits
`slice × rate` overflows an int64 for large amounts — a slice near maxInt64 at any rate above 1 basis point — so the product is taken in 128 bits with `bits.Mul64` and divided with `bits.Div64`.
The discipline is ADOPTED, not derived, and the source is p/curve, read in full before this was written. Its guard carries the argument in its own comment: without the `hi < m` check, `bits.Div64` violates its own precondition and over-issues. See DISCOVERY.md § 3 and § 4, which attribute it.
Here that precondition is *provable*: rates are capped at RateScale, so `slice × rate < 2^63 × 10^4 < 2^77` and `hi < 2^13 = 8192 < 10000 = m`. **It is checked anyway**, and so is the quotient's fit in an int64, which is provable from the same cap. A proof that rests on the rate cap is a proof that expires silently the day someone raises the cap, and CLAUDE.md § 7a's whole subject is checks that stop asking their question.
Every guard in `chargeSlice` is therefore unreachable through the exported API, which makes them untestable through it. They are not left unexercised: `chargeSlice` is unexported and the suite calls it directly — [TestChargeSliceGuardsAPreconditionTheAPICannotReach] — so each guard has a test that fails when it is removed.
No count is given here, deliberately. This paragraph said "Both guards" when it meant the two OVERFLOW guards specifically, and when the rate-sign guard was added the obvious repair — "three" — was wrong twice over: the function holds four guards, and two of the four are preconditions rather than overflow checks, so any single number here answers an ambiguous question. CLAUDE.md § 4a's rule applies — prefer deleting a restated number over updating it — and the sabotage harness counts them by construction anyway.
An alternative was considered and rejected: binding the amount to `maxInt64/RateScale` so a plain int64 multiply could not wrap. That bound is safe for any µGNOT quantity and is refused for exactly that reason — justifying it needs a unit, and this package declares it has none. See DISCOVERY.md § 4.
The rate-sign guard was added after an adversarial audit, and the reason it was missing is worth more than the fix. The paragraph above proves the precondition from the rate *cap*, and a cap is an upper bound: writing it out established `rate <= RateScale` and said nothing about the other end, while the `slice < 0` guard sitting one line away made the function look as though both of its signed inputs had been considered. Measured, before the fix: `chargeSlice(1, -1)` returned `(1844674407370955, nil)` — a positive charge, a nil error, and both overflow guards cleanly passed, because `uint64(-1)` has a zero high word when multiplied by 1.
What made it survive review is the near miss: `chargeSlice(maxInt64, -1)` *is* caught, by `hi >= m`, so the negative-rate family is half-covered by an existing check and a spot probe at the interesting end of int64 reports an error exactly as it should. The hole is at the *small* end, where nothing about the value looks extreme. A guard that catches the dramatic case and misses the mundane one reads, from the outside, like a guard that works.
What bounds the work
Tier lookup is a linear scan over the edges, cheaper than a binary search at these sizes and the shape an auditor can check by reading. MaxTiers is 16, so there are at most 15 edges. One Schedule.Charge performs at most 16 128-bit multiplications and divisions over a 16-entry partition, and every exported method re-runs `check`, costing at most 50 further comparisons.
That 50 is `2 + 2 + (MaxTiers-2) + 2×MaxTiers`, and the decomposition is pinned by [TestConstantsAreTheDocumentedValues] rather than left as a number in prose beside a constant nobody checked. This project has shipped that defect more than once.
Those are the figures, not an assertion that they are affordable. A realm pricing many amounts in one transaction, or doing it inside a `Render`, must size them against the gas it has. This package bounds the work; it does not price it.
The one-tier schedule, and what it subsumes
Empty edges are legitimate: `New(nil, []int64{250})` is a flat 2.5% on the whole amount, with one tier and no breakpoint. It is the degenerate case of the same formula rather than a special path.
That schedule reproduces p/nt/grc721/royalty's arithmetic — `salePrice * Bps / FeeDenominator` against the same 10000 denominator — with one difference in the implementation and none in the result for the amounts that package can take. Royalty multiplies before dividing in plain int64, so it is implicitly bounded at about `maxInt64/10000 ≈ 9.2e14`; this package's 128-bit path has no such bound. [TestOneTierSchedule] pins the agreement over the range where both are defined.
p/aging is refused as a comparison in the other direction: it bins a *set* of items by each item's age, where this slices *one* amount by magnitude. A bin is not a slice, and the measurable consequence is the monotonicity section above. See DISCOVERY.md § 3.
What this package does not claim
- **No novelty for the formula.** A graduated rate schedule is textbook arithmetic and three independent mul-div helpers already exist in the ecosystem. What was not found in the searched sources is this shape: a partition of one amount into magnitude slices at caller-declared edges, each priced at its own rate. See DISCOVERY.md § 1 and § 3, which also record a functionally exact mul-div primitive that was found, classified reusable, and deliberately not imported.
- **No unit, deliberately.** The amount, the edges and the slices are int64 in whatever unit the caller keeps consistently. A rate is dimensionless. This is the stronger half of p/aging's rule, and here it costs nothing because there is no time axis to pick a convention for.
- **Nothing about money.** A charge is a number in a report. This package never sees a coin, and a realm using it must not infer custody or a transfer from it.
- **No opinion on where the edges and rates come from.** Picking them is the whole of a fee policy and none of it is here. They are validated for shape and range, never for plausibility — and, unlike p/aging, not for ordering either. A transposed rate array is accepted.
- **No schedule identity, and no versioning.** A Schedule is a value with no name and no effective date. A realm that changes its fees must keep its own record of which schedule priced which charge, because nothing here can tell two schedules apart or say when one took effect.
- **Not a rounding policy.** The floor is the only rounding this package performs and it is not configurable. A caller needing round-half-up, or needing the exact rational, must compute it from Schedule.SliceAt and the rates, both of which are exported for that purpose.
p/aging: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging p/depreciation: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation p/curve: /p/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/curve/v0 p/nt/grc721/royalty: /p/nt/grc721/royalty/v0