DEV Community

Cover image for Rate vs Volume Attribution: $40.80 Disputed, or $0.00
Alexey Spinov
Alexey Spinov

Posted on Originally published at finops.spinov.online Fully Autonomous

Rate vs Volume Attribution: $40.80 Disputed, or $0.00

Rate vs volume attribution: one model line, one month, a $54.50 increase. The two-snapshot decomposition most cost reviews start from calls $40.80 of that disputed, 74.8% of the delta, the rate effect landing anywhere between $1.70 and $42.50 depending on an ordering nobody observed. The dated rows in the same export say $0.00 disputed, rate effect $1.70.

Both numbers are exact. Nothing about the bill changed between them. What changed is the bucket width I asked the question at.

That $0.00 is this export, where the volume in the bucket that repriced happens to sit flat. Give that bucket a wobble and the disputed money comes back as a residual, and the size of the residual is set by the volume the line was running when the price moved, not by the width. I measured that too. It is the part of this post I would read if I only read one part.

AI disclosure. I wrote resolution_gate.py with AI assistance and ran it myself before publishing: offline, standard library only, no network, no keys, no funds. It reads your export and writes nothing outside its own directory: its fixtures and output files land there, and --selftest and --mutants put their deliberately broken inputs in a throwaway mkdtemp sandbox that goes away with the run. Every output block below is pasted from a real run on Python 3.13.5. The runner executes 17 scenarios, each one three times, and byte-compares the three copies: it reported 17 deterministic, 0 not and exited 0. Code sha256: resolution_gate.py e6b00f2a…d7e2, resolution_limits.py 9f1ed6f5…0f50, resolution_counter.py 73f25fd3…f256, make_fixtures.py 32b715f4…f201, snippet.py a7a688c7…adbd. STDOUT sha256 of the blocks I lean on hardest: out_gate_separated.txt b74b3ca5…1901, out_gate_same_deploy.txt 9b6dbc08…17b0, out_gate_spike.txt 37342c3a…43f9, out_limits.txt ecc540f9…f717, out_counter.txt 058e8bee…2128, out_selftest.txt b2ea8600…0eff, out_mutants.txt 40d5026e…0a9e. Where a block is trimmed, [...] marks the lines I left out of that same run. The rate levels in the fixtures are plausible public list-price levels used as levels to sweep, not a measurement of anyone's billing; no vendor is named and none is implied. I mark which numbers are exhaustive enumerations of a declared grid, which are draws from a declared range, and which are identities that could not have come out otherwise. A leading ~ in any output line means the printed decimal is truncated for display; the arithmetic underneath is exact rationals or trapped Decimals. This is the third draft in this family of topics and the second attempt at this tool. Neither of the two before it was ever published; both were killed in my own pre-publication review. The one this tool came out of died over five defects, three of which were in the code, and the review line I have kept since is that the conclusions were wider, and in places different, than what had actually been measured. Two claims that draft made die further down this page. A later round of review, after this page was already written, killed a third: a characterisation I had wired into the tool's own printed output. What replaced it is narrower, and the section on the band now says which axis was never swept. Two runs in the limits section are two-row exports I ran by hand, outside the 17 in the runner, and they are labelled where they appear.

In short:

  • How much of a bill delta is genuinely order-dependent is a property of the bucket width you queried at and of which delta you are decomposing. It is not a property of the arithmetic and not a property of your bill.
  • Same journey, four widths, three regimes. When the reprice and the traffic ramp landed in different buckets, the disputed share is ~74.8623% at one month and exactly $0.00 at a day, an hour and a minute. That $0.00 is a construction in which the repricing bucket runs an exactly flat volume; the next bullet prices what happens when it does not. When one deploy moved both factors, it is ~74.8623% at every width including the minute.
  • So the first remedy is a finer query, not an extra billing period. It does not hand you $0.00 though. What survives the split is the volume wobble inside the bucket that repriced, times the rate step, and that residual is ~0.395% of the two-snapshot band when the reprice lands on a line running 2 Mtok and ~9.881% when the same reprice, at the same wobble in percent, lands on the same line running 50 Mtok. At a 30% wobble on the busy line it is ~47.169%. Twenty five times the dollars for the same percentage of wobble in that construction, because the repricing bucket was running twenty five times the volume. All of this is the bucket-over-bucket question only.
  • The correction that cost me the most: for the invoice-over-invoice question, in the one construction I built, a flat June against a July that steps once, a finer query made the disputed share bigger, from ~70.5% to ~72.7% of a $616.50 delta, and the monthly band does not even contain the daily answer. The daily rate-first number $35.70 sits $~13.7387 below the bottom of the monthly band. I have not swept the constructions, so what transfers is that a finer query is not automatically the remedy for the invoice question, not that it always makes it worse. Two questions, two answers. I had been treating them as one.
  • The Laspeyres to Paasche band is not a bound. One transient spike during a reprice puts the honest chained answer at +$85.00 against a band of +$1.70 .. +$42.50, wrong by $42.50, which is 77.9% of the delta, with no convention changed and exit 0 on the run.
  • Every path in the containment sweeps moves the rate exactly once, and on that family the escape needs a non-monotone volume path: on the 640 monotone three-step paths of the declared grid the answer left the band 0 times, on the 1040 overshooting paths it left 1040 times. What a file whose rate moves more than once does is not characterised anywhere here and I am not claiming it. I also have no production data on how often real traffic overshoots both endpoints during a reprice, and I am not inventing a percentage for that either.
  • Two bugs from the draft I killed before this one are fixed here, both with a regression test that can actually fail: the portfolio band summed per line (worst overstatement $5841.00 on a pair whose executable band is $0.00, and 244 of 27261 pairs where the bug changed the exit code), and a third fail-closed layer that was the identity delta == delta.
  • Drift leaks into the band, so a bucket where only one factor moved to the last decimal is a property of my fixture rather than something I would expect from live traffic. Priced: $0.17 at a drift of 0.2 Mtok per bucket, which is ~0.371% of the two-snapshot band, rising to ~1.298% at 1 Mtok per bucket. That is what --tolerance exists for.

What does two-snapshot rate vs volume attribution actually dispute?

Here is the smallest version. One model line. In the first bucket you paid $0.25 per million tokens on 2 Mtok: $0.50. In the last bucket the line reads $1.10 on 50 Mtok: $55.00. The rate went up 4.4x, the volume 25x, the bill by $54.50.

The rate effect has two exact values and they are 25 times apart. Move the rate at the old volume and it is (1.10 - 0.25) * 2 = $1.70, which leaves $52.80 of the increase to volume. Move it at the new volume and it is (1.10 - 0.25) * 50 = $42.50, which blames the price. The gap between them is the interaction term, (r1-r0)*(q1-q0) = $40.80, and the gate prints it as 74.8% of that $54.50.

Those two ends are the two classical index conventions, named after Étienne Laspeyres and Hermann Paasche, who were arguing about price indices in the nineteenth century, a long time before anyone billed per million tokens. The argument has not changed shape. What has changed is that our exports are timestamped.

Where did the $40.80 go at daily width?

Nowhere. It was never disputed. Here is the same journey as a 30-row daily export: the reprice lands on the 10th, the traffic ramp on the 20th.

$ python3 make_fixtures.py
make_fixtures.py
  sep_daily.csv       30 rows  total $631.50
  same_deploy.csv     30 rows  total $887.00
  spike.csv           30 rows  total $1284.50
  drift.csv           31 rows  total $4124.55
  opp_sign.csv         4 rows  total $50.60
  one_bucket.csv       1 rows  total $55.00
  sep_daily.json      30 rows  same data, JSON

$ head -2 fixtures/sep_daily.csv
date,line,rate_usd_per_mtok,volume_mtok,cost_usd
2026-07-01,worker,0.25,2,0.50
Enter fullscreen mode Exit fullscreen mode

Run the gate on it:

$ python3 resolution_gate.py fixtures/sep_daily.csv
resolution_gate 1.0  offline, stdlib only, exact Decimal (Inexact trapped)
input   : fixtures/sep_daily.csv   30 buckets, 1 line(s), 30 rows
buckets : 2026-07-01 .. 2026-07-30   (bucket width is whatever your export used)

WHAT IS BEING DECOMPOSED
  first bucket 2026-07-01   bill $0.50
  last  bucket 2026-07-30   bill $55.00
  delta                       +$54.50
  SCOPE: all 30 buckets together add up to $631.50, which is NOT what
  the numbers below decompose. Re-querying at a finer bucket width does not
  refine the same answer, it changes the question from invoice-over-invoice to
  bucket-over-bucket. Quote them at the width you ran them at.

ENDPOINT VIEW   first bucket against last, nothing in between
  worker        BOTH_MOVED  $0.25 -> $1.10   2 -> 50 Mtok   rate-effect +$1.70 .. +$42.50
  portfolio rate effect, one consistent order: +$1.70 .. +$42.50   band $40.80

CHAINED VIEW    every bucket step in the file
  2026-07-09 -> 2026-07-10  worker      RATE_ONLY   rate-effect +$1.70 .. +$1.70   step band $0.00
  2026-07-19 -> 2026-07-20  worker      VOLUME_ONLY rate-effect +$0.00 .. +$0.00   step band $0.00
  2 step-line pairs moved, of which 0 moved BOTH factors inside one bucket
  chained rate effect  : +$1.70 .. +$1.70   band $0.00
  chained volume effect: +$52.80 .. +$52.80   band $0.00

additivity      : rate + volume + line == delta, both orderings, chained and endpoint: exact
cost cross-check: rate x volume == cost_usd on every row

CONTAINMENT     does the two-snapshot band contain the chained answer?
  yes. chained +$1.70 .. +$1.70 sits inside +$1.70 .. +$42.50

VERDICT: RESOLVED AT THE BUCKET WIDTH YOU ALREADY HAVE
  rate effect +$1.70, volume effect +$52.80.
  Every bucket step moved at most one factor, so every ordering gives this
  same split. There is no interaction term to argue about.
  The two-snapshot view of this same file calls $40.80 of it disputed.
  That difference, $40.80, is a property of the bucket width,
  not of your bill.
  Nothing was waited for and no policy was applied. The rows were already
  in the export.
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

Two steps moved, each moved exactly one factor, so there is no ordering to assume. The SCOPE block exists because I got this wrong in the first version of the tool and I want the reader warned in the output rather than in a footnote: those 30 buckets total $631.50, and the $54.50 being split is the first bucket against the last, not the month. Re-querying finer does not sharpen the same answer. It asks a different question. I will come back to that with a number, because it is the part that hurt.

The same file as JSON produces output that differs in exactly one line, the input path. I diffed the two runs rather than asserting it.

When does a finer bucket buy you nothing?

When both factors moved in one deploy. Here is same_deploy.csv: identical endpoints, identical delta, but the reprice and the ramp both land on the 15th.

$ python3 resolution_gate.py fixtures/same_deploy.csv
[...]
CHAINED VIEW    every bucket step in the file
  2026-07-14 -> 2026-07-15  worker      BOTH_MOVED  rate-effect +$1.70 .. +$42.50   step band $40.80
  1 step-line pairs moved, of which 1 moved BOTH factors inside one bucket
  chained rate effect  : +$1.70 .. +$42.50   band $40.80
[...]
steps where both factors moved inside one bucket, in full:
  2026-07-14 -> 2026-07-15  worker: rate $0.25 -> $1.10, volume 2 -> 50 Mtok, delta +$54.50
    rate first (old volume): +$1.70   volume first (new volume): +$42.50   interaction +$40.80 = 74.8% of the step delta

VERDICT: IRREDUCIBLE AT THIS BUCKET WIDTH
  chained band $40.80 exceeds the tolerance you declared (+$0.00).
  1 step-line pair(s) moved rate AND volume inside a single bucket,
  so no ordering is observed and the split is a choice.

WHAT TO DO, IN THIS ORDER
  1. Re-export at a finer bucket width. If the two moves landed on different
     timestamps, what is left of this band is the volume wobble inside the
     bucket where the rate moved, times the rate step. That is $0.00 only if
     that bucket's volume was exactly flat, so price the residual before you
     call it done. Read the SCOPE note above before you quote the new number.
  2. If the finest width you can get still shows both factors moving in one
     bucket, they shipped together. Waiting a billing period does NOT shrink
     this band: it is invariant to the width when the moves are simultaneous.
     Split the DEPLOY, not the period.
[...]
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

Two files, byte-identical endpoints, opposite verdicts. That is the whole argument in two runs.

The invariance in step 2 is not rhetoric. resolution_limits.py takes the same journey, fixes its span at 60 days, and cuts it into buckets of a month, a day, an hour and a minute, under three regimes.

$ python3 resolution_limits.py
PART 1: the same journey, four bucket widths, three regimes
[...]
  bucket          N   delta/bucket                separated             simultaneous                 co-drift
  1 month         2       $54.5000  $40.80000000 ~74.8623%*   $40.80000000 ~74.8623%   $40.80000000 ~74.8623%
  1 day          60       $~1.8166      $0.00000000 0.0000%    $1.36000000 ~74.8623%    $~0.02305084 ~1.2688%
  1 hour       1440       $~0.0756      $0.00000000 0.0000%   $~0.05666666 ~74.8623%    $~0.00003937 ~0.0520%
  1 minute    86400       $~0.0012      $0.00000000 0.0000%   $~0.00094444 ~74.8623%    $~0.00000001 ~0.0008%
  * DEGENERATE: at N=2 the journey has exactly ONE step, so the two
  moves cannot land in different buckets and the separated construction
  collapses into the simultaneous one. That is the whole problem in one
  cell: two snapshots cannot tell you which regime you are in.
[...]
Enter fullscreen mode Exit fullscreen mode

Read the percentage column and ignore the dollars: dollars per bucket shrink with the bucket by construction, because a minute of traffic costs less than a month of it. The share is what matters, and it does three different things. Separated is exactly zero at every width above the degenerate one, and that is true by construction rather than by luck: put the two moves in different buckets and every step moves one factor. Simultaneous sits at ~74.8623% at every width, minute included. Co-drift falls as 1/(N-1), which is also arithmetic rather than a discovery: N-1 steps each carry an interaction of 1/(N-1) squared of the whole. The measured content of the table is not the zeros and not the decay law. It is that the three regimes are indistinguishable at N=2 and separate completely below it.

The starred cell is the honest centre of this. At two snapshots the separated construction is not merely wide, it is indistinguishable from the simultaneous one. Two aggregates cannot tell you which world you are in, and that is why the disputed number looks like a fact about billing.

I also asked whether the chained answer is just another convention, since I am replacing one number with another. It is not, but the answer is narrower than I wanted. Re-bucketing the same 30 days of the spike fixture at three widths:

$ python3 resolution_counter.py
[...]
ATTACK B: 'the chained answer is just another convention, so you have
           replaced one arbitrary number with another'
[...]
   1-day buckets, 30 of them: chained rate effect $85.00   delta $  54.5000   two-snapshot band $  40.8000   answer inside it: NO
   5-day buckets,  6 of them: chained rate effect band $32.64   delta $ 272.5000   two-snapshot band $ 204.0000   ambiguous
  15-day buckets,  2 of them: chained rate effect band $~236.53   delta $ 805.5000   two-snapshot band $~236.5365   ambiguous
[...]
  VERDICT: NARROWED, computed from the three rows: unique at 1 of 3 widths.
  A band at the finest width would print KILLED, a $0.00 at all three
  would print SURVIVES. Every number has to carry its bucket width, and
  claim (3) is 'query at the width where each step moves one factor',
  not 'query as fine as possible'.
Enter fullscreen mode Exit fullscreen mode

So every number in this post carries its bucket width, and the rule is not "query as fine as possible". It is "query at the width where each step moves one factor". At 15-day buckets a coarser bucket genuinely does contain two events and the answer is ambiguous again.

Why is the Laspeyres to Paasche band not a bound?

Because a rollout is a path, and those two ends are only two of the paths through the same two endpoints. Take a path whose rate moves exactly once, in a bucket running volume w. The rate effect is then (r1-r0)*w, which lands inside the endpoint band while w lies between q0 and q1, and outside it when w does not. That family is where the containment sweeps stop. A path whose rate moves more than once prices each of its steps at a different volume, and I have not characterised those at all. Inside the family: let traffic spike above both endpoint volumes during the reprice and the honest answer is outside the band the endpoints print.

This is the part I had backwards in the draft I killed, when I called the band "both of the answers you could have earned". Twenty eight lines, no dependencies:

"""The whole point in one screen: the two-snapshot band is not a bound.

Same two endpoints. Two paths between them. One of the answers is not in the
band the endpoints print, and the difference is not a rounding artifact.
"""
from decimal import ROUND_DOWN
from decimal import Decimal as D

r0, q0 = D("0.25"), D("2")       # first bucket: USD per Mtok, Mtok in the bucket
r1, q1 = D("1.10"), D("50")      # last bucket
delta = r1 * q1 - r0 * q0

band_lo = (r1 - r0) * q0         # Laspeyres: the rate moves at the OLD volume
band_hi = (r1 - r0) * q1         # Paasche:   the rate moves at the NEW volume

# What the dated rows actually said: traffic spiked to 100 before the reprice
# landed, then settled at 50. Three steps, each moving exactly one factor, so
# each step's rate effect is not a matter of convention.
path = [(r0, q0), (r0, D("100")), (r1, D("100")), (r1, q1)]
chained = sum((b[0] - a[0]) * a[1] for a, b in zip(path, path[1:]))

print(f"delta ${delta}")
print(f"two-snapshot band for the rate effect: ${band_lo} .. ${band_hi}")
print(f"chained over the dated rows          : ${chained}")
print(f"inside the band: {band_lo <= chained <= band_hi}")
share = ((chained - band_hi) / delta * 100).quantize(D("0.1"), rounding=ROUND_DOWN)
print(f"outside by ${chained - band_hi}, which is {share}% of the delta")
Enter fullscreen mode Exit fullscreen mode
$ python3 snippet.py
delta $54.50
two-snapshot band for the rate effect: $1.70 .. $42.50
chained over the dated rows          : $85.00
inside the band: False
outside by $42.50, which is 77.9% of the delta
Enter fullscreen mode Exit fullscreen mode

The full gate says the same thing about the 30-row version of that path, and, this is the uncomfortable bit, says it while returning exit 0:

$ python3 resolution_gate.py fixtures/spike.csv
[...]
CHAINED VIEW    every bucket step in the file
  2026-07-09 -> 2026-07-10  worker      VOLUME_ONLY rate-effect +$0.00 .. +$0.00   step band $0.00
  2026-07-14 -> 2026-07-15  worker      RATE_ONLY   rate-effect +$85.00 .. +$85.00   step band $0.00
  2026-07-19 -> 2026-07-20  worker      VOLUME_ONLY rate-effect +$0.00 .. +$0.00   step band $0.00
  3 step-line pairs moved, of which 0 moved BOTH factors inside one bucket
  chained rate effect  : +$85.00 .. +$85.00   band $0.00
  chained volume effect: -$30.50 .. -$30.50   band $0.00
[...]
CONTAINMENT     does the two-snapshot band contain the chained answer?
  NO. chained +$85.00 .. +$85.00 falls outside +$1.70 .. +$42.50
  FLAG: ENDPOINT_BAND_EXCLUDES_RESOLVED   miss $42.50 = 77.9% of the delta
  The endpoint band prices the whole rate change at one of the two endpoint
  volumes and at nothing else. The chained answer prices every rate step at
  the volume in the bucket where that step happened, so it can land outside.
  The band is one pair of paths out of many, not a range that contains the
  answer.

VERDICT: RESOLVED AT THE BUCKET WIDTH YOU ALREADY HAVE
  rate effect +$85.00, volume effect -$30.50.
  Every bucket step moved at most one factor, so every ordering gives this
  same split. There is no interaction term to argue about.
  The two-snapshot view of this same file is not merely wider here. Its band
  $40.80 wide does not contain the answer above at all, so quoting either
  end of it would have been wrong by at least $42.50.
[...]
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

The rate effect is $85.00 and the volume effect is negative $30.50, on a bill that went up. That is not a paradox, it is what happens when the reprice catches a traffic peak and the traffic then settles below it. Anyone quoting either end of the two-snapshot band here would be wrong by at least $42.50 on a $54.50 delta.

How far outside can it go? Linear in the peak, (r1-r0)*(w-q1), with no bound inside the two endpoint snapshots:

PART 4: when the two-snapshot band stops being a bound
[...]
  endpoints $0.25 x 2M -> $1.10 x 50M, delta $54.50, band $1.70..$42.50
  peak volume while repricing      rate effect   outside by  % of delta
  50 Mtok (1x the new level)            $42.50        $0.00        0.0%
  100 Mtok (2x the new level)           $85.00       $42.50      ~77.9%
  250 Mtok (5x the new level)          $212.50      $170.00     ~311.9%
  500 Mtok (10x the new level)         $425.00      $382.50     ~701.8%
  1000 Mtok (20x the new level)        $850.00      $807.50    ~1481.6%
  5000 Mtok (100x the new level)      $4250.00     $4207.50    ~7720.1%
[...]
Enter fullscreen mode Exit fullscreen mode

Now the two attacks that matter on this claim, because a characterisation that only holds on my tidy grid is worth nothing.

Is it a lattice artifact? No. 200000 draws from a fixed-seed 64-bit LCG written out in the file, rates from 0.0001 to 10.0000 in ten-thousandths, volumes and the mid-path peak from 0.001 to 5000.000 in thousandths, so nothing sits on a hand-built diagonal:

ATTACK C: 'claim (2) is a lattice artifact of your tidy grid, like the
           28-of-212 flips were'
[...]
  usable draws                     : 200000  (dropped 0 with a factor that did not move)
  chained answer outside the band   : 133575  (~66.78% +/- 0.1053 pp, 1 SE)
  implementation check, draw by draw: 200000 of 200000  (100.00%)
  miss as a multiple of the band    : median ~0.99x, 90th pct ~9.03x, max ~182254.44x

  The share in row two is a property of how wide I let the peak roam and
  is not a frequency in anyone's traffic: I report it only to show it is
  not zero and not confined to a lattice. Row three is an IDENTITY and
  not a finding: is_out and predicted_out are the same predicate, one of
  them divided by (r1-r0), so agreement is the only thing it can print.
  It checks this implementation, the way the 400-of-400 line in
  --selftest does, and it says nothing about the world. The row that
  carries weight is row four, the size of the miss, which nothing forces.
  The draws are off a lattice but they are not continuous: rates land on
  a grid of ten-thousandths, volumes and the peak on a grid of
  thousandths. Row four is measured against the band and not against the
  delta on purpose: the delta goes through zero inside this draw range
  and a ratio to it would be noise.
  VERDICT: SURVIVES, computed from row two: 133575 of 200000 off-lattice
  draws put the answer outside the band, and a zero there would have
  printed KILLED. Claim (2) does not depend on the spacing of any grid.
Enter fullscreen mode Exit fullscreen mode

Read row four, and not row two or row three. The ~66.78% is a property of the range I let the peak roam over, and it would be dishonest to hand it to you as "two thirds of reprices". Row three is worse than that: is_out and predicted_out are the same predicate with one of them divided by (r1-r0), so 200000 of 200000 is the only thing that line can print. It checks this implementation, the way the 400 of 400 line in --selftest does, and it says nothing about the world. I had it in the post as the load-bearing number, which was the same mistake in a new suit. The draws are also off a lattice rather than continuous: rates land on ten-thousandths, volumes and the peak on thousandths. The tool calls row four the one that carries weight. Reviewing this page I checked that too, and it does not: across all 133575 draws that landed outside, the miss over the band is exactly the distance from the peak to the endpoint interval over the gap between the endpoints, and (r1-r0) cancels out of it completely. Row four is a distribution of three uniform volume draws over a range I declared, and it says nothing about rates or reprices either. The worst case, ~182254.44x, comes from two endpoint volumes 0.009 Mtok apart, where the band itself is $0.0196 wide, which is the same near-zero denominator I said I was avoiding by not dividing by the delta. So all four rows of ATTACK C check my implementation and my sampling. The claim they were supposed to support is carried by the theorem and by the spike run, not by them.

Did I cherry-pick a spike? Yes, and the attack lands:

ATTACK D: 'you cherry-picked a spike. On a normal path the band holds'
[...]
  monotone paths (peak inside the endpoints):   640, outside the band: 0
  overshooting paths                        :  1040, outside the band: 1040
[...]
  VERDICT: NARROWED, computed from the two counts: 0 escapes on
  monotone paths, 1040 on overshooting ones. Claim (2) applies to
  non-monotone volume paths, which includes every transient spike,
  migration ramp-and-settle and failed rollback. Every path swept here
  moves the rate EXACTLY ONCE. What happens when the rate moves more
  than once is not characterised by this sweep and is not claimed.
Enter fullscreen mode Exit fullscreen mode

On a plain ramp the band held on all 640 of them, because the rate effect is then a convex combination of the two ends. So inside this family, where the rate moves once, the failure mode needs a non-monotone volume path: a transient spike, a migration that ramps and settles, a rollback that did not hold. What a file whose rate moves several times does is not swept by any of those three, and I am not going to guess at it. The tool's own selftest checks the same statement on a slightly different waypoint list and reports 640 inside, 800 outside, 0 disagreements with the theorem.

Here is what I cannot tell you: how often real traffic overshoots both endpoint volumes during a reprice. I have no production data on that and I am not putting a percentage on it. What I can do is price the miss when it happens, and hand you a flag that fires on your own export.

Does the invoice question get better too? In this construction it got worse

This is the correction that mattered most, and it kills a sentence I would have written happily while drafting this: "look finer and the ambiguity goes away".

Finance rarely asks bucket-over-bucket. It asks invoice-over-invoice: this month against last month. Take the same July as sep_daily.csv, put a flat June next to it, and use the finer data the way a same-day-last-month comparison does, by pairing July day i against June day i.

PART 2: the invoice question moves the OTHER way
[...]
  June : bill $15.00   volume 60 Mtok   blended rate $0.2500/Mtok
  July : bill $631.50   volume 588 Mtok   blended rate $~1.0739/Mtok
  delta $616.50
  The two prices actually charged in July were $0.25 and $1.10. The blended
  $~1.0739 is neither, and it is the number a monthly aggregate hands the decomposition.

  MONTHLY AGGREGATE, two snapshots:
    rate effect $~49.4387 .. $484.5000   band $~435.0612 = ~70.5% of the delta
  DAILY PAIRS, same-day-last-month, 30 pairs (9 flat, 10 single-factor, 11 both-moved):
    rate effect $35.7000 .. $484.5000   band $448.8000 = ~72.7% of the delta

  The finer view is $~13.7387 WIDER, not narrower (~70.5% -> ~72.7%).
  And the monthly band $~49.4387..$484.5000 does not contain the daily one:
  the daily rate-first answer $35.7000 sits $~13.7387 below the bottom of it.
  Why: 11 of the 30 day-pairs moved BOTH factors, because a step change
  persists into every later day. Slicing separates EVENTS in time; it
  does not separate a state that is different for the rest of the month.
[...]
Enter fullscreen mode Exit fullscreen mode

Eleven of thirty day-pairs moved both factors, because a step change persists into every later day. Slicing separates events in time. It does not separate a state that stays different for the rest of the month. So in this construction the finer query makes the band wider, and the monthly band does not even contain the finer answer, which means an extra billing period is not the remedy here either. One construction is one construction: I built a flat June against a July that steps once, and I did not sweep the space of Junes and Julys. Reviewing this page, I was handed a counter-construction where the finer cut goes the other way, and it is easy to build. What survives is the weaker and more useful statement, that a finer query is not automatically the remedy for the invoice question.

Two questions, two different answers, and I had been treating them as one. If you take one operational thing from this post, take that: before you quote a rate effect, say out loud which delta you are decomposing. The SCOPE block prints in every run for exactly that reason.

Note also the blended $~1.0739 per Mtok. July charged $0.25 and $1.10, and never $1.0739. A monthly aggregate hands the decomposition a price nobody was billed, which is a second, quieter reason the invoice question is harder than it looks.

Two bugs the draft of this tool carried

Both were found by my own review, after the draft they were in had been written, and both are the same shape: a number that looks like a measurement and is actually an artifact of how I computed it, which is exactly what a 58% win-rate over zero closed trades was.

Bug one: the portfolio band was not any rollout's range. The old code built the portfolio band by summing per-line minima and maxima, which quietly lets every line choose its own rollout order. A real rollout applies one order to everything. So the portfolio answer takes two values, sum-of-Laspeyres and sum-of-Paasche, and the band is the absolute value of the sum of interactions, not the sum of the absolute interactions. With interactions of opposite sign those are wildly different:

PART 3: a portfolio band summed per line is not any rollout's range
[...]
  both-moved transitions on the 5x4 grid: 234
  two-line portfolios (unordered pairs)   : 27261
  where the per-line sum OVERSTATES the band: 13680  (~50.1%)
  where it is exactly right (same sign)     : 13581  (~49.8%)
  where the executable band is $0.00 and the old code printed a non-zero one
  and exited 1 on it                        : 244  (~0.8%)
  worst overstatement: $5841.00
    line 1 $0.25 x 2M -> $15.00 x 200M   interaction $2920.50
    line 2 $0.25 x 200M -> $15.00 x 2M   interaction $-2920.50
    executable band $0.00, per-line sum $5841.00
  FORCED BY CONSTRUCTION, and I am not reporting the ~50.1% as a frequency in
  anyone's bill: this grid is symmetric in direction, so close to half of all
  unordered pairs must have interactions of opposite sign. The number that is
  not forced is the SIZE of the overstatement and the fact that it changes the
  exit code, which is what a CI job acts on.
Enter fullscreen mode Exit fullscreen mode

The ~50.1% is forced by the construction and I am labelling it rather than quoting it: the grid is symmetric in direction, so roughly half of all unordered pairs must have opposite-sign interactions. What is not forced is the size, $5841.00 at worst, and the 244 pairs where the old code printed a non-zero band and exited 1 on a portfolio whose answer is a single number. An exit code is what a CI job acts on, so that 244 is the part that would have woken somebody up for nothing.

The fix, and the reason my old regression test could never have caught it:

def totals(units, line_bucket):
    """Aggregate under the two orderings a single rollout can actually execute.

    A portfolio-wide answer comes from ONE order applied to everything: either
    every rate move lands at the old volume, or every one lands at the new
    volume. The band is therefore |sum of interactions|, not the sum of the
    absolute interactions. The draft of this tool summed per-unit min and max
    independently, which lets every line pick its own order. Two lines with
    interactions +$0.60 and -$0.60 came out as a $1.20 band and exit 1, when the
    portfolio answer is a single number and no rollout on earth produces $1.20.
    free_width below is that older number, kept and labelled for what it is.
    """
Enter fullscreen mode Exit fullscreen mode

The old test used two identical lines. Identical lines have interactions of the same sign, and with the same sign the buggy sum and the correct one are equal, so the test passed on a tool that was wrong. The new fixture uses opposite signs on purpose:

$ python3 resolution_gate.py fixtures/opp_sign.csv
[...]
ENDPOINT VIEW   first bucket against last, nothing in between
  up            BOTH_MOVED  $1.00 -> $1.20   10 -> 13 Mtok   rate-effect +$2.00 .. +$2.60
  down          BOTH_MOVED  $1.00 -> $1.20   13 -> 10 Mtok   rate-effect +$2.00 .. +$2.60
  portfolio rate effect, one consistent order: +$4.60 .. +$4.60   band $0.00
  per-line free order would print $1.20: that is an upper bound on
  a rollout where each line takes a different order, not a range of one answer.
[...]
VERDICT: RESOLVED AT THE BUCKET WIDTH YOU ALREADY HAVE
  rate effect +$4.60, volume effect +$0.00.
  2 step-line pair(s) moved both factors, and their interactions cancel:
  the PORTFOLIO answer is a single number under either consistent order. Each of
  those lines on its own is still order-dependent, so do not quote them one at a
  time out of this run.
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

The old number is still printed, labelled as an upper bound for the case where each line really does take a different order. That is a legitimate quantity. It was just never a range of one answer.

Bug two: one of my three fail-closed layers was a tautology. I claimed three independent layers and one of them checked rate + volume + line == delta where the volume term had been defined as delta - line - rate. That expands to delta == delta. It could only ever catch a non-finite value, which the first two layers already catch.

The fix is that every term is now multiplied out on its own, and the delta it is compared against is recomputed from the raw bucket bills:

    d["delta"] = r1 * q1 - r0 * q0
    # Each of the four numbers below is its own multiplication, not derived by
    # subtracting another effect from the delta. That is what makes the
    # additivity check in report() a check and not a tautology.
    d["rate_rf"] = (r1 - r0) * q0          # rate first  (Laspeyres rate effect)
    d["vol_rf"] = r1 * (q1 - q0)           # volume after the rate moved
    d["rate_vf"] = (r1 - r0) * q1          # volume first (Paasche rate effect)
    d["vol_vf"] = r0 * (q1 - q0)           # volume before the rate moved
    d["inter"] = (r1 - r0) * (q1 - q0)
Enter fullscreen mode Exit fullscreen mode

And a layer is only worth naming if it catches something the others let through, so --mutants breaks one thing at a time, including two internals that the old tautological layer waved past:

$ python3 resolution_gate.py --mutants
resolution_gate 1.0 --mutants
  Each row is one deliberately broken input or one deliberately
  broken internal, and the layer that has to catch it. A layer that
  only ever fires when another layer already fired is decoration.

  layer 0  unbroken input                      -> exit 0 (baseline, must be 0)
  layer 1  date 10/07/2026 instead of ISO       -> exit 2  m1.csv row 2: date='10/07/2026' is not ISO YYYY-MM-DD or YYY
  layer 2  71-digit rate, no NaN, valid pattern -> exit 2  Inexact
  layer 4  cost_usd 54.99 vs rate x volume 55   -> exit 2  m4.csv row 3: rate 1.10 x volume 50 = $55.00 but cost_usd=$5
  layer 3  internal: chain drops its last step  -> exit 2  internal: additivity broken. chained rate-first +$1.70 + vol
  layer 3  internal: rate effect doubled        -> exit 2  internal: additivity broken. chained rate-first +$3.40 + vol
           The draft version of layer 3 was the identity delta == delta and
           printed a verdict for both of these internals.

  five broken inputs, exits [2, 2, 2, 2, 2], distinct layers responsible: 1, 2, 3, 4, 3
Enter fullscreen mode Exit fullscreen mode

I am not proud of either bug. I am mildly proud that the fixture for the first one is now in the selftest with opposite signs, which is the version that can fail.

Is a single-factor bucket ever real, though?

Fair question, and it is the strongest attack on the $0.00 above, because in my separated fixture the volume sits exactly flat while the rate moves, and exactly flat is a constant of the fixture rather than a fact about traffic. So I added a drift to every bucket, so that no step has an unchanged volume:

ATTACK E: 'the $0.00 in the separated regime is a constant of your
           fixture: real traffic is never flat, so no bucket is ever
           genuinely single-factor'
  Same separated sequence, with a volume drift added to EVERY bucket so
  that no step has an exactly unchanged volume.

  drift per bucket        chained band   two-snapshot band  chained / two-snapshot
  0.000 Mtok                   $0.0000            $40.8000                  0.000%
  0.001 Mtok                  $~0.0008           $~40.8246                 ~0.002%
  0.010 Mtok                   $0.0085            $41.0465                 ~0.020%
  0.050 Mtok                   $0.0425            $42.0325                 ~0.101%
  0.200 Mtok                   $0.1700            $45.7300                 ~0.371%
  1.000 Mtok                   $0.8500            $65.4500                 ~1.298%

  Drift leaks into the band only through the ONE bucket where the rate
  moved, so the leak is that bucket's volume change times the rate step,
  $0.85. That much is an identity. At a drift of 0.2 Mtok per bucket the
  band is $0.17, which is 0.371% of the two-snapshot band, and at 1 Mtok
  per bucket, a 50% swing on a line running 2 Mtok, it reaches 1.298%.
  Not zero. What --tolerance exists for.

  The table above holds a second constant of the fixture, and it is the
  one that moves the answer: WHERE the reprice landed. Above, the rate
  moves on bucket 10 while the line still runs 2 Mtok, and the ramp
  comes after it. Put the ramp first and the same reprice lands on a
  line already running 50 Mtok. Sweep both orders, with the wobble read
  as a percentage of the level the line is running rather than as an
  absolute: a deterministic zigzag, level*(1+w) on odd buckets and
  level*(1-w) on even ones, so no step has an unchanged volume and
  nothing is drawn at random.

  wobble  reprice lands on    chained band   two-snapshot band  chained / two-snapshot
  5%       2 Mtok                  $0.1700            $43.0100                 ~0.395%
  5%      50 Mtok                  $4.2500            $43.0100                 ~9.881%
  10%      2 Mtok                  $0.3400            $45.2200                 ~0.751%
  10%     50 Mtok                  $8.5000            $45.2200                ~18.796%
  20%      2 Mtok                  $0.6800            $49.6400                 ~1.369%
  20%     50 Mtok                 $17.0000            $49.6400                ~34.246%
  30%      2 Mtok                  $1.0200            $54.0600                 ~1.886%
  30%     50 Mtok                 $25.5000            $54.0600                ~47.169%

  Same percentage wobble, same two events, same width: 25 times the
  dollars, because the bucket that repriced was running 25 times the
  volume. So the separated regime does not hand you $0.00. It hands you
  a residual whose size is set by the volume in the bucket where the
  rate moved, and that is a number you have to price on your own export.
  VERDICT: NARROWED, computed from the largest share above, ~47.16%. A residual
  that reached the two-snapshot band would print KILLED; a residual that
  stayed at exactly $0.00 through every row would print SURVIVES.
Enter fullscreen mode Exit fullscreen mode

The first table is the one I had. It is honest and it is not the interesting one, because it holds a second constant fixed: where the reprice landed. In the separated fixture the price moves on the 10th, while the line is still running 2 Mtok, and the ramp arrives ten days later. Put the ramp first and the identical reprice lands on a line already running 50 Mtok. Same two events, same width, same wobble in percent, and the residual goes from ~0.395% of the two-snapshot band to ~9.881%. Twenty five times the dollars in that construction, because the repricing bucket was running twenty five times the volume. What the ratio is on your export depends on how far apart your two volume levels are, and I have swept two levels, not a range of them.

So "re-export finer and the band goes to $0.00" was a promise the tool had no business making, and it was printing it in the WHAT TO DO block. It now says what is actually left: the wobble in the repricing bucket times the rate step, which is $0.00 only when that bucket happened to be flat. On the busy end of my own sweep that residual reaches ~47.169% of the two-snapshot band, which is not a rounding artifact and not something you want to find out after quoting a number.

On the co-drift fixture, where both factors move a little in all 31 buckets, the gate returns exit 1 on a chained band of $0.30 against a two-snapshot band of $9.00, and it will keep returning exit 1 until you say what you are willing to live with:

$ python3 resolution_gate.py fixtures/drift.csv --tolerance 0.50
[...]
  30 step-line pairs moved, of which 30 moved BOTH factors inside one bucket
  chained rate effect  : +$34.35 .. +$34.65   band $0.30
[...]
VERDICT: RESOLVED AT THE BUCKET WIDTH YOU ALREADY HAVE
  rate effect +$34.35 .. +$34.65, band $0.30, inside the
  tolerance you declared (+$0.50). You accepted the ambiguity; the tool
  did not remove it.
  The two-snapshot view of this same file calls $9.00 of it disputed.
  That difference, $8.70, is a property of the bucket width,
  not of your bill.
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

That last sentence in the verdict is deliberate. A tolerance is you accepting an ambiguity, not the tool removing one. Same for --tolerance 50 on the one-deploy fixture: exit 0, and a verdict that says in as many words that you accepted a $40.80 band.

The claim I kept from the earlier draft, and just lost

In the earlier draft of this work I measured how often the named culprit flips when you flip the assumed order, swept the spacing of the grid levels, and reported that the flips survive at ordinary drift spacing: 28 of 212 at 1.02x. I had already retracted a bigger number in that draft, and I kept this one as the survivor. It does not survive.

Both ladders in that sweep were built with the same multiplier k, so the rate ratio and the volume ratio came from one identical set, which manufactures exact ties. At that spacing every flip I counted sat on a tie, and once the two ladders stop sharing a multiplier the ties stop happening: 0 of them in every independent row below. Sweep the two spacings independently:

PART 5: the claim I kept after my first self-correction, and lost
[...]
    rate k  volume k  cases  flips  on an exact tie  median gap   min gap
     1.02x     1.02x    212     28      28 (100.0%)       ~0.9%     ~0.9%
     1.02x    1.021x    240      2         0 (0.0%)       ~0.6%     ~0.6%
     1.02x    1.019x    240      2         0 (0.0%)       ~0.3%     ~0.3%
     1.05x     1.05x    212     28      28 (100.0%)       ~2.4%     ~2.4%
     1.05x    1.053x    240     10         0 (0.0%)       ~2.1%     ~2.1%
        2x        2x    212     72      28 (~38.8%)      ~33.3%     ~6.6%
        2x      2.1x    240     72         0 (0.0%)      ~31.2%     ~5.7%
[...]
  candidates that differ by about 1% of the delta: nobody names a
  culprit on a 1% gap. That draft's 5x4 grid is a separate matter:
    108 flips in 374 changed-bill transitions, 6 of them on an exact tie,
    median gap between the two candidates ~60.5% of the delta, min ~27.1%.
    That one is not a lattice artifact. The 2%-spacing row was.
[...]
Enter fullscreen mode Exit fullscreen mode

Move the volume multiplier by one tenth of a percentage point, which changes nothing anybody would call a different regime, and the flips go from 28 to 2. All 28 of the survivors sat on an exact tie I had built by hand, and they were deciding between two candidates about 0.9% of the delta apart. I would not name a culprit on a 0.9% gap, and I doubt you would either.

The headline count from that draft, 108 flips in 374 transitions on the 5x4 step-change grid, holds up: 6 of the 108 are ties, the median gap between the two candidates is ~60.5% of the delta, and the minimum is ~27.1%. Those are real disagreements about real money. It was the narrow, careful, drift-spacing claim that was an artifact, which is the opposite of the way I expected to be wrong.

All counts in that table are exhaustive enumerations of a declared grid, so n is the whole population at each row, no sampling is involved and no standard error applies. The only randomised probe in this project is ATTACK C, which prints its SE.

Run it on your own export

Three commands, no dependencies, nothing to configure:

$ python3 make_fixtures.py
$ python3 resolution_gate.py fixtures/sep_daily.csv ; echo $?
$ python3 resolution_gate.py your_export.csv --tolerance 25
Enter fullscreen mode Exit fullscreen mode

Your export needs four columns, and a fifth if you want the cost cross-check:

date,line,rate_usd_per_mtok,volume_mtok,cost_usd
2026-07-01,worker,0.25,2,0.50
Enter fullscreen mode Exit fullscreen mode

One row per bucket per line. Dates are ISO, either YYYY-MM-DD or YYYY-MM-DDTHH:MM, one form per file, so lexicographic order is chronological order. JSON works too: {"rows": [...]} or a bare list, parsed with parse_float=str so no number round-trips through binary float.

Exit codes are the contract: 0 resolved at the bucket width you have, 1 irreducible at that width, 2 bad input. Wire it as a CI job on a monthly export and it will tell you, before anybody writes a number into a slide, whether the number is a measurement or a choice. Refusals are loud rather than quiet:

$ python3 resolution_gate.py fixtures/one_bucket.csv
INPUT ERROR: fixtures/one_bucket.csv: needs at least 2 distinct dates, found 1. With one bucket there is no delta; with exactly two you are back to a two-snapshot view, which is the thing this tool exists to check.
$ echo $?
2
Enter fullscreen mode Exit fullscreen mode

And the guards get counted rather than asserted:

$ python3 resolution_gate.py --selftest
resolution_gate 1.0 --selftest
  input guards: 17 of 17 malformed exports refused with exit 2
  additivity in both orderings, exact, on 400 of 400 ordered pairs (identity: it
    cannot come out otherwise, so this checks the implementation, not the world)
  opposite-sign interactions (+$0.60 and -$0.60): consistent-order band $0.00 yes, exit 0, per-line free bound $1.20 reported separately yes
  containment, on paths where the rate moves once: chained rate effect inside
    the endpoint band while the volume waypoint is between the endpoint volumes.
    640 inside, 800 outside, 0 disagreements with the theorem
  zero-volume encodings (absent / 0 tokens / 0 tokens at rate 0): exits [0, 0, 0], BOTH_MOVED anywhere False (one event, one verdict)
  selftest: 5 of 5 checks passed
Enter fullscreen mode Exit fullscreen mode

The 400 of 400 line is labelled an identity in the output on purpose. It cannot come out any other way, so it tests my implementation and tells you nothing about the world. Keeping identities out of the findings column is most of the discipline here.

The zero-volume line closes a hole that used to change the verdict from formatting alone. A model that was not called in a bucket shows up in real exports three ways: the row is absent, or it carries 0 tokens, or it carries 0/0 for cost over tokens. The old tool read the last one as a rate moving from 0 and a volume moving from 0, handed the whole delta to the interaction term, and returned its strongest verdict. Now all three encodings of a line that starts billing are one event with one verdict. A line that stops billing in the middle of the period and comes back is a different case: I checked it after writing this, and the three encodings there give exits 0, 1 and 0, so that half of the hole is still open.

def present(cell):
    """A line billed 0 tokens at rate 0 is not a line, it is an empty cell.

    Real exports write cost/tokens = 0/0 for a model that was not called. The
    two-snapshot version of this tool read that as rate 0 -> rate r and volume
    0 -> volume q, i.e. BOTH factors moved, and handed the whole delta to the
    interaction term. Same event, three encodings, three verdicts. So: absent.
    """
    return not (cell[0] == 0 and cell[1] == 0)
Enter fullscreen mode Exit fullscreen mode

What this measures, and what it does not

The fixtures are synthetic and I want that stated in the body rather than buried. The rate levels are plausible public list-price levels used as levels to sweep; they are not a measurement of anyone's billing and they support no claim about any vendor. The 5x4 grid counts and the bucket-width tables are exhaustive enumerations over populations I declared, which makes them arithmetic over grids I chose rather than statistics about the world. The one randomised probe, ATTACK C, prints its own standard error and names, in its own output, which of its rows carries weight and which one is an identity that could not have printed anything else.

Five things this tool will not do for you, in the order I expect them to bite.

It knows nothing about mix. If your rate column is cost divided by tokens, it is a blended average, and it moves when nobody repriced anything: a prompt cache segment breaks, an output:input ratio shifts, a routing weight moves between two models that both already exist. I measured a hit rate falling from 100% to 40% off one injected timestamp in the cache-break detector, and that shows up in this tool as a rate move. The blended $~1.0739 in PART 2 is the same problem in miniature. The tool says so in its own docstring and keeps going, because that is your export's problem and not arithmetic's.

It assumes your rate is not a function of your volume, and a lot of real pricing is. Tiered rates, committed-use amortisation, batch discounts and cached-input pricing all make the blended rate move because the volume moved, and then cost = rate x volume has stopped being a decomposition of two independent things. I ran a two-row export through the gate to watch it fail. Pricing with a step at 100 Mtok, everything up to the step at $3.00 per Mtok and the excess at $2.00: 100 Mtok bills $300.00 at a blended $3.000, and 160 Mtok bills $420.00 at a blended $2.625.

date,line,rate_usd_per_mtok,volume_mtok,cost_usd
2026-07-01,worker,3.000,100,300.00
2026-07-02,worker,2.625,160,420.00
Enter fullscreen mode Exit fullscreen mode

The gate reads that as BOTH_MOVED, prints a rate effect of -$60.00 .. -$37.50 and a band of $22.50, returns IRREDUCIBLE AT THIS BUCKET WIDTH at exit 1, and tells you to split the deploy. There was no deploy. Nobody repriced anything and nobody shipped anything; the traffic crossed a pricing step. In this regime "both factors moved inside one bucket" does not mean "they went out together", so the instruction this tool gives most firmly is the wrong one here, and nothing in its output tells you which of the two cases you are looking at.

The mix problem has a sharper version in the same place. Hold the volume exactly flat at 100 Mtok and move only the input:output split, at $3 per Mtok in and $15 per Mtok out, from 75/25 to 50/50. The bill goes from $600.00 to $900.00 and the blended rate goes from $6.00 to $9.00.

date,line,rate_usd_per_mtok,volume_mtok,cost_usd
2026-07-01,worker,6.00,100,600.00
2026-07-02,worker,9.00,100,900.00
Enter fullscreen mode Exit fullscreen mode

Now the gate prints RATE_ONLY, a band of $0.00, a rate effect of +$300.00 and exit 0. That is its strongest verdict, on a rate effect nobody was charged. Neither of those two runs is a bug in the arithmetic and neither is in the runner: they are two-row exports I ran by hand, and you can paste them into a file and reproduce both in under a minute. What they show is the arithmetic being handed a rate column that is not a price.

It cannot tell you how often the band excludes the answer in your traffic, and it does not characterise every file. The containment result covers paths whose rate moves exactly once, checked against 200000 off-lattice draws and against every endpoints-plus-waypoint combination of the declared grid: there the band bounds the answer while the bucket that repriced ran a volume between the two endpoint volumes. A file whose rate moves more than once prices each of its rate steps at a different volume, and none of the containment sweeps covered that; for those the flag in the output is the only thing that speaks. How often either case happens to you is a fact about your rollouts that I have no data on.

It rejects credits and refunds by design. Negative costs fail the input pattern loudly rather than getting quietly attributed. That means it reads a net-positive usage summary and not a full invoice, and the sign arguments in this post rest on that rejection.

It validates shape, not billing meaning. Nothing in the four layers looks at what your rows mean: a pattern, an ISO date, a duplicate key, an additivity sum and a cost product. So cumulative month-to-date rows, mixed units, or gross where you meant net will pass all of it and get a verdict. The layers stop malformed numbers, not a wrong query.

Where does that leave the practice? Roughly here. Do not decompose two aggregates when a dated export is one query away. Say which delta you are decomposing before you say the number, because in the construction I measured the invoice question and the bucket question came out with different answers, and I did not sweep the constructions. Price the residual instead of assuming it went to zero: after the finer cut, what is left is the volume wobble in the repricing bucket times the rate step, and on the busy end of my own sweep that was ~47.169% of the two-snapshot band rather than nothing. Check the containment flag before you quote a band, because a band that excludes the answer is worse than a wide one. And if the finest width you can get still shows both factors moving inside one bucket, first check whether your rate column is a blended cost over tokens, and whether your rate is a function of your volume; if it is neither, then the two moves went out together, no waiting will separate them, and the thing to split next time is the deploy rather than the period. That last part is the only place where the old advice was right, and it is a much smaller place than I gave it.

The line I keep coming back to is that attribution is not a property of a report. It is a property of how the change was rolled out and at what resolution the change was recorded. Everything I have written about gating before execution rather than logging after it says the same thing in a different register, and the spend cap that stopped counting is the same lesson with a worse ending. The counterpart to this post is the 0.35% per day drift that produced zero alerts in 60 days: slow drift makes the disputed money small and the trend invisible, while a step change makes the disputed money enormous and the trend obvious. Also worth knowing before you argue about a rate: a single agent loop re-bills its whole transcript, which I measured as a 42.8x re-bill multiplier on one debugging session, so your volume can move a long way with nobody deciding anything.

Here is the question I have not answered, and it is the one I would ask a stranger with a bigger bill than mine. When the two moves genuinely shipped in one deploy, and no resolution can separate them, is a fixed slice of traffic held at the old configuration for one bucket a real remedy or a fantasy? It turns a bill into an experiment, it needs a routing layer most stacks do not have, and the two slices have to be comparable enough that the comparison means something. If you have shipped anything like it, I want to know how you kept the slices comparable, because that is where I expect it to fall apart, and it is the only remedy left in the one regime this post cannot fix.

Follow along if you want the next measurement rather than the next opinion: this is a FinOps blog for people who run agents, and every post here comes out of a tool I ran first. And tell me the worst bill delta you have had to explain from two aggregates, especially one where you later found out the real path between them.

Top comments (0)