From 778a848062045bd0921df20c24149f074032abfe Mon Sep 17 00:00:00 2001 From: agenticode <16611333+agenticode@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:18 +0900 Subject: [PATCH] feat(explain): decompose the Fargate residual into a Charges dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit charges is an ordinary member of Terms whose Of children are charge-volume, charge-kind-mix, charge-class-mix, charge-rate and charge-unattributed. Residual is still computed last as the remainder, so sum(Terms)+Residual == Delta is untouched and check, Verify, Citations, Sum and Prose cover the new dimension with no new code — an invariant enforced in two places is enforced in neither. Attribution.check asserts charges.Micro == ChargeDeltaMicro, the exact int64 difference of the two supplied dimensions. Without it sum(Of) == parent is vacuous and charge-unattributed absorbs whatever the parent claims. FuzzChargeInvariants (6.0M execs clean) additionally pins that the chain's remainder never exceeds what 2 microUSD/h quantization can explain. Charges stated at one window edge only are not attributed at all: reading the silent edge as zero would report a cluster's whole Fargate bill as appearing from nothing. TestResidualStillGrowsWhenChargesCannotExplainIt pins the complementary direction, and TestChargesShrinkTheResidualTheyUsedToBe asserts the microUSD leaving the residual equals the charges term with no node term moving to make room. Charge.Evidence is exported so a CostBasis still round-trips through JSON; enforcement lives in NewCharge and at the input boundary, where an uncitable charge fails the whole answer rather than being dropped. Co-authored-by: kording <74226694+kording@users.noreply.github.com> --- pkg/explain/CHARGES-FINDINGS.md | 364 ++++++++ pkg/explain/charges.go | 797 ++++++++++++++++++ pkg/explain/charges_fuzz_test.go | 308 +++++++ pkg/explain/charges_test.go | 779 +++++++++++++++++ pkg/explain/prose.go | 6 + .../FuzzChargeInvariants/ac25dfe8a8b5152b | 2 + pkg/explain/whycost.go | 132 ++- 7 files changed, 2381 insertions(+), 7 deletions(-) create mode 100644 pkg/explain/CHARGES-FINDINGS.md create mode 100644 pkg/explain/charges.go create mode 100644 pkg/explain/charges_fuzz_test.go create mode 100644 pkg/explain/charges_test.go create mode 100644 pkg/explain/testdata/fuzz/FuzzChargeInvariants/ac25dfe8a8b5152b diff --git a/pkg/explain/CHARGES-FINDINGS.md b/pkg/explain/CHARGES-FINDINGS.md new file mode 100644 index 0000000..dee9790 --- /dev/null +++ b/pkg/explain/CHARGES-FINDINGS.md @@ -0,0 +1,364 @@ +# pkg/explain — the `Charges` dimension (why-cost, second dimension) + +`cmd/WIRING-FINDINGS.md` §6.4 bullet 3: *"`why-cost` prices Fargate into the +residual… correct and coarse; the natural extension is a second `CostBasis` +dimension (`Charges []Charge`) decomposed by the same chain."* This is that +extension. + +``` +gofmt -l pkg/explain/ pkg/pricing/ → empty +go vet ./... → clean +go build ./... → clean +go test -race -count=1 ./pkg/explain/... ./pkg/pricing/... → ok, 91.8% statements in pkg/explain +go test -race -short ./... → ok (whole repo) +go.mod / go.sum → unchanged +``` + +19 new test functions + 1 new fuzz target. Fuzzing run locally: **6.0 M execs +`FuzzChargeInvariants`** clean, plus **3.1 M execs `FuzzWhyCostInvariants`** +re-run to prove the existing property still holds. No existing test was +edited, weakened or deleted; `testdata/golden/whycost_base.json` and +`whycost_base_prose.txt` are byte-identical (every new payload field is +`omitempty`, and no note fires on an input with no charges). + +`pkg/pricing` was **not touched.** The rule was "a pricing-side accessor only +if the decomposition genuinely needs one"; it does not. Everything the wiring +below needs — `Catalog.FargateRates`, `FargateRates.Cost`, +`FargateConfig.String`, `ClusterCost.Fargate` — is already exported, and +`pkg/explain` must not import `pkg/pricing` anyway (money.go duplicates +`HoursPerMonth` rather than take that dependency). + +--- + +## What the dimension is + +```go +type Charge struct { + Kind string // "fargate", "ebs", … — a *policy* grouping + Class string // "1vCPU/2GB", "gp3", … — a *shape* inside the kind + Units int64 // pods, GiB, invocations + UnitUSDPerHour float64 // the price of ONE unit + Evidence []ID // required, and must parse +} + +type CostBasis struct { + … + Charges []Charge + ChargesKnown bool +} +``` + +`Charge` is deliberately the same shape as `NodeGroup` — a count and a unit +price — because that is what lets the *same chain* decompose it. `Cost = Σ u·r` +is the identity on both sides; only the names of the two grouping dimensions +differ (lifecycle/instance-type there, kind/class here). + +**`ChargesKnown` is a flag, not a nil check.** This is bug 1 of the original +unit applied to the second dimension: an empty slice has to be able to mean +"we looked, there were none", which is a different and more useful claim than +"we did not look". Charges are attributed **only when both edges set it**. A +dimension stated at one edge alone is refused with a note and the whole move +stays in the residual — reading the silent edge as zero would report a +cluster's entire Fargate bill as having appeared out of nothing, which is +exactly the large, confident, wrong number this package exists to not print. +`TestChargesAtOneEdgeAreRefusedNotZeroed` pins it, and the fuzzer asserts that +a half-known dimension leaves no trace in any payload field. + +## The decomposition chain and its order + +``` +charge-volume → charge-kind-mix → charge-class-mix → charge-rate +``` + +echoed into every payload as `Attribution.ChargeOrder`, and into the prose: + +``` +Attribution order: node-count → spot-ratio → instance-mix → pricing-catalog → charges +Charge attribution order: charge-volume → charge-kind-mix → charge-class-mix → charge-rate +``` + +- **`charge-volume` first**, at the window's **starting** mix and **starting** + rates. It is the one charge term an operator can verify independently: the + unit count (Fargate pods) is a number they already know, and the reference + per-unit rate is printed as the `referenceUnitUSDPerHour` fact. +- **`charge-kind-mix` before `charge-class-mix`.** A charge *kind* is a + placement policy someone chose ("run this on Fargate"). A charge *class* is + largely a consequence — which configuration a pod lands on is AWS's rounding + rule applied to its requests (`pkg/pricing` §4.1). Giving the interaction to + the consequence keeps the policy term equal to "what moving that workload + off Fargate would have cost at the old shape mix". + `TestChargeKindMixIsThePolicyTerm` pins it. +- **`charge-rate` last**, weighted at end-of-window units (Paasche). A rate + change you did not cause belongs against the units you actually run now. A + line that appears mid-window borrows the other edge's rate, so "rates rose" + can never mean "you bought something" (`TestChargeRateMeansRatesMoved`), and + a supplied row with **zero units** states a rate nobody was paying and gets + no rate term (`TestZeroUnitChargeLineHasNoObservedRate`) — the same rule + `priceAt` applies to node groups. + +### What a different order would have said + +`TestChargeAttributionOrderIsTheDocumentedOne` pins the choice against the +equally defensible alternative, on a fixture where the two disagree: + +| | reference rate | `charge-volume` | +|---|---|---| +| **chosen: volume at the START class mix** | `$0.200000 / 10 units` = **$0.020000**/unit-h | 2 × $0.020000 = **+$0.040000/h** | +| alternative: volume at the END class mix, start rates | `(4×$0.0125 + 8×$0.05) / 12` = $0.037500/unit-h | 2 × $0.037500 = **+$0.075000/h** | + +Nearly a 2× difference on the same input, and both are defensible — which is +precisely why the convention is asserted rather than assumed. The test refuses +to pass if the two conventions ever coincide, because a fixture that cannot +tell them apart proves nothing. + +## The exact arithmetic identity, and where it is enforced + +``` +sum(Terms) + Residual == Delta (unchanged) +charges.Micro == ChargeDeltaMicro (new) + == Σ(end u·r) − Σ(start u·r), exact int64 +sum(charges.Of) == charges.Micro (new) +``` + +All three are re-verified on the finished payload in **`Attribution.check`** — +the first two by `checkCharges`, the third by the sub-term loop that already +existed. `WhyCost` returns an error rather than ship a payload that violates +any of them. + +**The invariant got stronger, not weaker.** `sum(Terms)+Residual == Delta` is +untouched: the `charges` term is an ordinary member of `Attribution.Terms`, so +`Residual` is still computed **last**, as the remainder, and can still grow. +What is new is that the charges term is not free to be any number: it must +equal the exact repriced difference of the two supplied dimensions. Without +that clause the third identity would be vacuous — `charge-unattributed` would +dutifully absorb whatever the parent claimed, which is the failure mode this +whole package refuses. + +`FuzzChargeInvariants` proves no rounding path can break either identity, and +adds the two assertions that carry the actual weight, since identities that +close by construction cannot fail on their own: + +1. **`charge-unattributed` ≤ 2 µUSD/h, always.** `charge-volume`, + `charge-kind-mix` and `charge-class-mix` are each quantized exactly once + from float share arithmetic (< ½ µUSD each); `charge-rate` and the parent + are exact integer arithmetic. Three half-µUSD errors cannot exceed 1.5, so + anything above 2 is a modelling error, not rounding. This is the assertion + that would catch a chain that failed to explain a real charge move. +2. **A closed model in *both* dimensions leaves only quantization in the + residual** (≤ 2 µUSD/h) — the two decompositions compose, rather than one + quietly eating the other's error. + +Plus, every exec: charge terms are citable and every id parses; reversing +every input slice (charge rows included) is byte-identical; the emitted term +order equals the stated `Order`; a half-known dimension populates nothing. + +### A bug the fuzzer found — in the test, on the first run + +`FuzzChargeInvariants` failed in 0.05 s on `[]byte("0102020100000")`: a +"closed" case where only the *end* edge stated its charges. The code was +right — it refused the one-sided dimension and left $23.04/h in the residual, +which is the designed behaviour — and the *test* was wrong to call that input +closed. A model built on evidence the package is designed to decline is not a +complete model. The closed branch now states both edges; the asymmetric shape +is still generated and still asserted, in the open branch. + +### Determinism + +Float sums go through `sumSorted` over canonically-ordered slices +(`ckey` sorts by `(Kind, Class)`); no map is ever iterated into output. +`TestChargeShuffleIsIdentical` explodes every line into single-unit rows, +shuffles them 64 ways, and requires byte-identical JSON — which exercises the +duplicate-row merge rule at the same time. Duplicate `(kind, class)` rows +merge by **unit-weighted average rate**, mirroring `newComp`, because two rows +for one line is a collector detail and must not become two terms +(`TestDuplicateChargeRowsMergeNotDouble`). + +## Citations: a `Charge` that cannot be cited cannot reach a term + +- **`NewCharge` is the constructor and it refuses**: no evidence, or evidence + that does not `Parse`, returns `ErrUncitableCharge` and a zero `Charge`. +- **A hand-built uncitable `Charge` fails the whole answer.** + `CostBasis.validate` → `validateCharges` rejects it, so `WhyCost` returns an + error. It is *not* dropped: silently dropping a line would understate the + charge total and hand the difference to a term that did not earn it. +- **Rows without the flag are an error too**, not a silent skip — money that + vanishes from both the terms and the operator's attention. +- `Evidence` is an exported field so a `CostBasis` still round-trips through + JSON (the API layer builds these). The trade-off is deliberate and is the + same one `Term.Evidence` already makes: enforcement lives at the boundary + and in `check`, not in an unexported field that would break the wire format. + +Charge terms cite **the lines that moved**, not merely something that +resolves: `charge-volume` cites lines whose unit count changed, +`charge-kind-mix` lines in kinds whose share moved, `charge-class-mix` lines +whose within-kind share moved, `charge-rate` lines whose rate moved plus any +`pricing-change` event. All are additionally anchored to the two timeline +points, which is the same standard the node terms hold themselves to and no +stronger. + +**`Verify` needed no new code**, and that is most of the argument for making +the charge chain a sub-attribution rather than a parallel top-level list: +`Attribution.Verify`, `Citations()`, `Sum()`, `check()` and `Prose()` all walk +`Term.Of` already, so the new dimension inherits the publish gate instead of +duplicating it — and an invariant enforced in two places is an invariant +enforced in neither. `TestVerifyCoversChargeCitations` proves it is real by +breaking a citation on the parent and on a child. + +## What still lands in the residual, and honestly why + +- **Charges stated at one edge only.** Refused by design (above). The whole + move stays in the residual under a note. +- **Any cost from a kind nobody supplied a line for.** The dimension can only + decompose what it is given; a charge class the collector does not know about + is invisible here and is in the residual by construction, exactly as before. +- **The gap between the modelled level and the meter.** The composition notes + now compare *fleet + charges* against the observed cost, so supplying + charges no longer produces a note claiming an unpriced gap that the charges + dimension just priced. What remains genuinely unpriced still lands in the + residual and still says so. +- **Node-side quantization**, ≤ 2 µUSD/h, as before. +- **Sub-µUSD disagreement with `pricing.ClusterCost`.** `SnapshotCost` sums a + float per *pod*; a `Charge` is `Units × MicroFromUSD(rate)`. The two can + differ by under 1 µUSD per line. That difference lands in the residual, not + in a term. +- **Fargate pods AWS refused to price** (`ClusterCost.Warnings`, "left + unpriced"). They are absent from `SnapshotCost.HourlyUSD` *and* must be + absent from `Charges` — a `Charge` with units and a zero rate would claim + they are free. Nothing is double-counted; the warning is the answer. + +`TestResidualStillGrowsWhenChargesCannotExplainIt` is the test that keeps this +section honest: supplied charges are flat, the meter says the bill rose +$0.29/h anyway, and the assertion is that every charge term is zero and the +residual holds the whole amount. A decomposition that can only ever shrink the +residual is not measuring, it is asserting. + +The complementary direction is `TestChargesShrinkTheResidualTheyUsedToBe`: +same cluster, same meter, run with and without the dimension supplied. It +asserts that the µUSD leaving the residual **equals** the charges term, and +that no node term moved to make room. A dollar may only leave the residual by +landing in a term that claims it. + +--- + +## Exact wiring `cmd/kilter` must do + +Out of scope for this unit by instruction. `cmd/kilter/explain.go`'s +`basisFrom` already skips Fargate nodes (`if n.IsFargate() { continue }`) with +a comment pointing here; it needs the second dimension filled in. + +### 1. Build `Charges` from `pricing.SnapshotCost` + +```go +func chargesFrom(snap *model.ClusterSnapshot, cat *pricing.Catalog, cite explain.ID) ([]explain.Charge, error) { + cost := cat.SnapshotCost(snap) // already includes Fargate pods + rates := cat.FargateRates() + type row struct{ units int64 } + agg := map[pricing.FargateConfig]*row{} // FargateConfig is comparable + for _, p := range cost.Fargate { // NOTE: snapshot order — do not + r := agg[p.Config] // append straight to the slice + if r == nil { r = &row{}; agg[p.Config] = r } + r.units++ + } + cfgs := make([]pricing.FargateConfig, 0, len(agg)) + for c := range agg { cfgs = append(cfgs, c) } + sort.Slice(cfgs, func(i, j int) bool { // map iteration must not reach output + if cfgs[i].MilliCPU != cfgs[j].MilliCPU { return cfgs[i].MilliCPU < cfgs[j].MilliCPU } + return cfgs[i].MemoryMiB < cfgs[j].MemoryMiB + }) + out := make([]explain.Charge, 0, len(cfgs)) + for _, c := range cfgs { + // c.String() renders "0.25vCPU 0.5GB" — a stable label, which is all + // Class has to be; the chain never parses it. + ch, err := explain.NewCharge(explain.ChargeKindFargate, c.String(), + agg[c].units, rates.Cost(c), cite) // NewCharge, never a literal + if err != nil { return nil, err } + out = append(out, ch) + } + return out, nil +} +``` + +and on the basis itself: + +```go +b.Charges, err = chargesFrom(snap, cat, explain.TimelineID(cluster, pointAt(snap.Timestamp))) +b.ChargesKnown = true // a snapshot always knows its pod set +``` + +Four things the wiring must get right: + +1. **`ChargesKnown = true` at BOTH edges or neither.** Setting it at one edge + is not a half-answer, it is a refused answer — you get a note and the old + coarse residual. If one window edge has no usable snapshot, set it at + neither. +2. **`ChargesKnown = true` even when there are no Fargate pods.** That is the + claim "we looked, there were none", and it is what makes the charges term + say `no non-node charges at either window edge` instead of vanishing. Rows + without the flag are rejected outright. +3. **`cite` must resolve against the store the answer is served from.** The + timeline point for the window edge is the minimum and is what the node + terms already use; `Verify` will 500 on anything that does not re-serve. + Once the substrate keeps per-pod Fargate evidence, cite that instead — the + `Charge.Evidence` field is a slice for exactly that reason. +4. **Sort before emitting.** `cost.Fargate` is in snapshot order and the map + above is a map. `pkg/explain` re-sorts internally, so a wrong order cannot + change the *answer* — but it can change `cmd`'s own JSON, and §7's + determinism contract is repo-wide. + +### 2. Surface the dimension in `kilter why-cost` + +- **Human output stays `Attribution.Prose()` verbatim.** The charges term and + its chain already render through the existing `Term.Of` path, and the charge + order line is already printed. There is nothing to add: + +``` +Between … the hourly cost of cluster "prod-eks-1" rose by +$0.290000/h (+$211.70/mo), from $1.200000 to $1.490000. +Attribution order: node-count → spot-ratio → instance-mix → pricing-catalog → charges (see package doc). +Charge attribution order: charge-volume → charge-kind-mix → charge-class-mix → charge-rate (see charges.go). + charges +$0.290000/h +$211.70/mo non-node charges (fargate) cost more [tl/… tl/…] + of which charge-volume +$0.040000/h +$29.20/mo 2 more charged units at the window's starting average rate of $0.020000/unit-hour […] + of which charge-class-mix +$0.210000/h +$153.30/mo charged units shifted toward more expensive configurations […] + of which charge-rate +$0.040000/h +$29.20/mo charge rates rose for 1 line […] + … + residual +$0.000000/h +$0.00/mo nothing unexplained [tl/… tl/…] +``` + +- **`--json` gains** `chargesKnown`, `chargeFromMicroUSDPerHour`, + `chargeToMicroUSDPerHour`, `chargeDeltaMicroUSDPerHour` and `chargeOrder`, + all `omitempty`, plus the `charges` entry in `terms`. A consumer that does + not know about the dimension sees no change on a cluster without one. +- **Update §6.4 bullet 3** of `cmd/WIRING-FINDINGS.md`: it currently says + Fargate is priced into the residual. After the wiring above it is priced + into `charges`, and only what the collector could not see is residual. +- **Keep `countPricedNodes`.** `cmd/kilter/explain.go` already counts only + node-backed nodes into the timeline point, so the existing "composition + holds N nodes but the timeline holds M" note does not fire spuriously on a + Fargate cluster. Nothing in this unit changes that, and nothing in the + wiring above should: `Charges` accounts for Fargate *money*, not Fargate + *nodes*, and the node dimension must keep excluding them. + +### 3. `pkg/api` + +`basisFrom` in §2 of `FINDINGS.md` grows the same two lines. Nothing else +changes: `Verify(Resolver{…})` already covers charge citations. + +--- + +## Deliberately not built here + +- **A `pkg/pricing` accessor.** Not needed (top of this file). The aggregation + above is four lines of `cmd/` glue over already-exported API, and putting it + in `pkg/pricing` would give that package an opinion about `pkg/explain`'s + shape that it should not have. +- **EBS / Lambda / data-transfer kinds.** The type is open — `Kind` is any + string and the chain treats it as a policy grouping, which + `TestChargeKindMixIsThePolicyTerm` exercises with a second kind — but no + collector produces them today, so wiring one would mean inventing the + numbers. +- **Per-workload charge attribution.** A `Charge` has no namespace, so the + charges chain has no analogue of `workload-set`/`workload-scaling`. Adding + one needs a pod→charge map the substrate does not keep, which is the same + wall `FINDINGS.md` records for per-workload node attribution. +- **A golden fixture for the charges payload.** The existing goldens are + deliberately untouched so a diff there still means someone changed the node + chain. A charges golden belongs with the `cmd/` wiring that will render it. diff --git a/pkg/explain/charges.go b/pkg/explain/charges.go new file mode 100644 index 0000000..2732c46 --- /dev/null +++ b/pkg/explain/charges.go @@ -0,0 +1,797 @@ +package explain + +import ( + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/agenticode/kilter/pkg/evidence" +) + +// The charges dimension: cost that is billed per *line item* rather than per +// node, decomposed by the same chain the node composition uses. +// +// # Why a second dimension at all +// +// A cluster's observed hourly cost is not only `Σ nodes × price`. EKS Fargate +// pods are billed by their quantized vCPU/memory configuration and have no +// shareable machine behind them (pkg/pricing/fargate.go); EBS, Lambda and +// friends have the same shape. `pkg/pricing.SnapshotCost` already includes +// them in the number the timeline stores, while [CostBasis.Groups] +// deliberately excludes Fargate "nodes" — pricing a single-pod VM by its +// reported shape is a silent overcharge. The gap therefore landed in +// [Attribution.Residual] with a note: honest, and coarse. +// +// This file makes it decomposable *without making it less honest*. Every +// dollar that leaves the residual lands in a term that names a specific +// line-item move, cites the charges it was computed from, and satisfies an +// arithmetic identity checked in [Attribution.check]. +// +// # The identity, and where it is enforced +// +// charges.Micro == ChargeDeltaMicro == Σ(end u·r) − Σ(start u·r) +// sum(charges.Of) == charges.Micro +// +// both re-verified in [Attribution.check] alongside `sum(Terms)+Residual == +// Delta`, which is unchanged: the charges term is an ordinary member of +// [Attribution.Terms], so the residual is still computed last, as the +// remainder, and can still grow. +// +// The children are the same chain as the node side, one level deep, so the +// existing machinery — `check`, [Attribution.Verify], [Attribution.Citations], +// [Attribution.Sum], [Attribution.Prose] — covers the new dimension with no +// change. Duplicating that machinery for a parallel top-level list would have +// meant duplicating the invariant enforcement too, and an invariant enforced +// in two places is an invariant enforced in neither. +// +// # Attribution order — and why it is this one +// +// charge-volume → charge-kind-mix → charge-class-mix → charge-rate +// +// - **charge-volume first**, at the window's starting mix and starting +// rates. It is the term an operator can check independently: the unit +// count (Fargate pods) is a number they already know, and the reference +// per-unit price is printed in the term's facts. Measured at the *end* +// mix instead, it would be priced against a configuration mix nobody +// remembers — see TestChargeAttributionOrderIsTheDocumentedOne, which +// pins a fixture where the two conventions disagree. +// +// - **kind before class.** A charge *kind* ("fargate", "ebs") is a +// placement policy someone chose. A charge *class* (which Fargate +// configuration a pod quantizes onto) is largely a consequence of the +// requests plus AWS's rounding rule. Giving the interaction to the +// consequence keeps the policy term equal to "what moving that workload +// off Fargate would have cost at the old shape mix", which is the +// counterfactual a human is actually checking. +// +// - **charge-rate last**, weighted at end-of-window units (a Paasche price +// index), for the same reason pricing-catalog is: a published rate change +// you did not cause belongs against the units you actually run now. A +// line that appeared mid-window borrows the other edge's rate, so +// "rate" always means *rates moved*, never *the line-item set moved*. +// +// # What still lands in the residual +// +// Everything it did before except the charge move itself. Charges supplied at +// only one edge are NOT attributed — a missing edge is not a zero — and the +// whole move stays in the residual under a note. Cost from a kind nobody +// supplied a line for is invisible here and stays in the residual by +// construction. + +// Charge kinds. The set is open — Kind is a caller-chosen label and the +// decomposition treats any string as a policy grouping — but the one this +// unit was built for has a name so the wiring and the tests agree on it. +const ( + // ChargeKindFargate is EKS Fargate pod capacity, priced per quantized + // vCPU/memory configuration (pkg/pricing §4.1). Class is the + // configuration, e.g. "0.25vCPU/0.5GB"; Units is the number of pods on it. + ChargeKindFargate = "fargate" +) + +// Term kinds for the charges dimension. `charges` is the top-level term; the +// rest are its sub-attributions, exactly one level deep. +const ( + TermCharges = "charges" + TermChargeVolume = "charge-volume" + TermChargeKindMix = "charge-kind-mix" + TermChargeClassMix = "charge-class-mix" + TermChargeRate = "charge-rate" + TermChargeUnattributed = "charge-unattributed" +) + +// chargeChainOrder is the order the charge sub-terms are peeled off in, and +// it is part of the package's contract — see the file doc above for why it is +// this order and not another. It is echoed into every payload as +// [Attribution.ChargeOrder]. +var chargeChainOrder = []string{TermChargeVolume, TermChargeKindMix, TermChargeClassMix, TermChargeRate} + +// Charge bounds. Like the node bounds they exist so the money arithmetic +// cannot overflow, and each is far above any real cluster: 4096 distinct +// (kind, class) lines, and 2^32 units — four billion Fargate pods, or four +// billion GiB of block storage. +const ( + maxCharges = 4096 + maxChargeUnits = int64(1) << 32 +) + +// ErrUncitableCharge reports a [Charge] with no parseable evidence id. +// +// It is a construction error, not a warning: [NewCharge] refuses to return +// such a charge and [CostBasis.validate] refuses the whole input, so a charge +// that could not be cited never reaches a term. An uncitable term looks like +// knowledge while being unfalsifiable, which is worse than the residual it +// would have been part of. +var ErrUncitableCharge = errors.New("explain: charge with no citable evidence") + +// Charge is one non-node line item at one window edge: Units of one +// (Kind, Class) at one unit rate. +// +// It is deliberately the same shape as [NodeGroup] — a count and a unit price +// — because that is what lets the same chain decompose it. `Cost = Σ u·r` is +// the identity on both sides; only the names of the grouping dimensions +// differ (lifecycle/instance-type there, kind/class here). +// +// UnitUSDPerHour is the price of ONE unit, not the line total. Lines with the +// same (Kind, Class) are merged by unit-weighted average rate before any term +// is computed, so a collector that reports a line twice does not become two +// terms. +// +// Evidence is required and must parse. Prefer [NewCharge], which enforces +// that at construction; a hand-built Charge with no evidence is rejected by +// [WhyCost] with [ErrUncitableCharge] rather than dropped, because silently +// dropping a line would understate the charge total and hand the difference +// to a term that did not earn it. +type Charge struct { + Kind string `json:"kind"` + Class string `json:"class,omitempty"` + Units int64 `json:"units"` + UnitUSDPerHour float64 `json:"unitUSDPerHour"` + // Evidence cites the observation this line was read from — at minimum the + // timeline point for the window edge, which is exactly the standard the + // node terms already hold themselves to. + Evidence []ID `json:"evidence"` +} + +// NewCharge builds a validated, citable charge. It is the only constructor +// that can fail, and it fails on precisely the things [WhyCost] would refuse: +// an empty kind, a negative or out-of-range unit count, an unusable rate, and +// — the one this unit exists to make impossible — no parseable citation. +func NewCharge(kind, class string, units int64, unitUSDPerHour float64, evidence ...ID) (Charge, error) { + c := Charge{Kind: kind, Class: class, Units: units, UnitUSDPerHour: unitUSDPerHour} + if len(evidence) > 0 { + c.Evidence = append([]ID(nil), evidence...) + } + if err := c.validate(); err != nil { + return Charge{}, err + } + return c, nil +} + +func (c Charge) key() ckey { return ckey{Kind: c.Kind, Class: c.Class} } + +func (c Charge) validate() error { + if strings.TrimSpace(c.Kind) == "" { + return fmt.Errorf("explain: charge with no kind") + } + if c.Units < 0 || c.Units > maxChargeUnits { + return fmt.Errorf("explain: charge %q unit count %d outside [0, %d]", c.key(), c.Units, maxChargeUnits) + } + if math.IsNaN(c.UnitUSDPerHour) || math.IsInf(c.UnitUSDPerHour, 0) || c.UnitUSDPerHour < 0 { + return fmt.Errorf("explain: charge %q unit rate %v is not a usable price", c.key(), c.UnitUSDPerHour) + } + if _, err := MicroFromUSD(c.UnitUSDPerHour); err != nil { + return err + } + if len(c.Evidence) == 0 { + return fmt.Errorf("%w: %s", ErrUncitableCharge, c.key()) + } + for _, id := range c.Evidence { + if _, err := Parse(id); err != nil { + return fmt.Errorf("%w: %s cites %q: %v", ErrUncitableCharge, c.key(), id, err) + } + } + return nil +} + +// validateCharges checks the charges dimension of a basis. It is called from +// [CostBasis.validate], so an uncitable charge fails the whole answer. +func (b *CostBasis) validateCharges() error { + if len(b.Charges) > maxCharges { + return fmt.Errorf("explain: %d charges exceeds the %d cap", len(b.Charges), maxCharges) + } + if len(b.Charges) > 0 && !b.ChargesKnown { + // Rows without the flag would be silently ignored, and a silently + // ignored line item is money that vanishes from both the terms and + // the operator's attention. + return fmt.Errorf("explain: %d charges supplied with ChargesKnown false; set the flag or drop the rows", len(b.Charges)) + } + var total int64 + for _, c := range b.Charges { + if err := c.validate(); err != nil { + return err + } + total += c.Units + if total > maxChargeUnits { + return fmt.Errorf("explain: total charge units exceeds the %d cap", maxChargeUnits) + } + } + return nil +} + +// chargesKnown reports whether this edge stated its non-node charges. A nil +// basis states nothing; `ChargesKnown` with no rows states "there were none", +// which is a different and perfectly real claim (bug 1's lesson, applied to +// the second dimension). +func (b *CostBasis) chargesKnown() bool { return b != nil && b.ChargesKnown } + +// chargeEdges folds both window edges' charges into canonical form and +// reports whether the dimension is attributable at all. +// +// It is attributable only when BOTH edges claim to know their charges. One +// edge alone is refused, loudly in Notes, because the alternative — reading +// the silent edge as zero — would report a cluster's entire Fargate bill as +// having appeared out of nothing or vanished into it. That is the exact shape +// of the lie this package is built to avoid: a large, confident, wrong number +// where an admission belonged. The whole charge move stays in the residual. +func chargeEdges(in Input, att *Attribution) (a, b *chargeComp, known bool, err error) { + if a, err = newChargeComp(in.Start); err != nil { + return nil, nil, false, err + } + if b, err = newChargeComp(in.End); err != nil { + return nil, nil, false, err + } + switch { + case in.Start.chargesKnown() && in.End.chargesKnown(): + return a, b, true, nil + case in.Start.chargesKnown() || in.End.chargesKnown(): + edge := "start" + if in.End.chargesKnown() { + edge = "end" + } + att.Notes = append(att.Notes, fmt.Sprintf( + "non-node charges are stated at the %s of the window only; a missing edge is not a zero, so no charge term is computed and the whole charge move stays in the residual", edge)) + } + // Not attributable: hand back empty dimensions so nothing downstream can + // accidentally price a half-known edge. + empty, err := newChargeComp(nil) + if err != nil { + return nil, nil, false, err + } + return empty, empty, false, nil +} + +// sum is the dimension's total, nil-safe so callers can add it to a fleet +// total without branching. +func (c *chargeComp) sum() Micro { + if c == nil { + return 0 + } + return c.total +} + +// modelSubject names what a composition note is comparing against the meter, +// so the note does not claim to have priced charges it never saw. +func modelSubject(edge string, chargesKnown bool) string { + if chargesKnown { + return edge + " composition and charges" + } + return edge + " composition" +} + +// ckey identifies a charge line. Comparable, so it is a safe map key; the +// canonical order is (Kind, then Class). +type ckey struct { + Kind string + Class string +} + +func (k ckey) less(o ckey) bool { + if k.Kind != o.Kind { + return k.Kind < o.Kind + } + return k.Class < o.Class +} + +func (k ckey) String() string { + if k.Class == "" { + return k.Kind + } + return k.Kind + "/" + k.Class +} + +// chargeComp is a validated, canonically-ordered charges dimension — the +// exact analogue of comp, and deliberately built the same way so the two +// dimensions cannot drift apart. +type chargeComp struct { + keys []ckey // sorted; the ONLY order anything iterates in + kinds []string + u map[ckey]int64 + r map[ckey]Micro + ev map[ckey][]ID + units int64 + total Micro // Σ u·r, computed in exact integer arithmetic +} + +// newChargeComp folds a basis's charges into canonical form. Duplicate +// (kind, class) rows are merged with a unit-weighted average rate, for the +// same reason newComp merges duplicate node groups: two rows for one line is +// a collector detail and must not become two terms. +func newChargeComp(b *CostBasis) (*chargeComp, error) { + c := &chargeComp{u: map[ckey]int64{}, r: map[ckey]Micro{}, ev: map[ckey][]ID{}} + if b == nil { + return c, nil + } + weighted := map[ckey]Micro{} // Σ u·r per line, exact + for _, ch := range b.Charges { + k := ch.key() + unit, err := MicroFromUSD(ch.UnitUSDPerHour) + if err != nil { + return nil, err + } + cost, err := mul(unit, ch.Units) + if err != nil { + return nil, err + } + if _, seen := c.u[k]; !seen { + c.keys = append(c.keys, k) + } + c.u[k] += ch.Units + c.ev[k] = append(c.ev[k], ch.Evidence...) + if weighted[k], err = add(weighted[k], cost); err != nil { + return nil, err + } + } + sort.Slice(c.keys, func(i, j int) bool { return c.keys[i].less(c.keys[j]) }) + for _, k := range c.keys { + u := c.u[k] + if u > 0 { + // Integer division truncates; total below is recomputed from the + // recovered rate, so the identity the terms satisfy is the one + // the terms were built from (newComp does the same). + c.r[k] = weighted[k] / Micro(u) + } + cost, err := mul(c.r[k], u) + if err != nil { + return nil, err + } + if c.total, err = add(c.total, cost); err != nil { + return nil, err + } + c.units += u + if n := len(c.kinds); n == 0 || c.kinds[n-1] != k.Kind { + c.kinds = append(c.kinds, k.Kind) + } + } + return c, nil +} + +// rateAt returns this dimension's unit rate for k, falling back to the other +// edge when the line did not exist here. A line that appears mid-window has +// no rate change to report — the whole of its cost is a volume or mix change +// — so borrowing the other edge's rate is what makes charge-rate mean "rates +// moved" rather than "the line-item set moved". +// +// "Did not exist here" includes a supplied row with zero units: a +// unit-weighted average over zero units is not a rate, and treating a +// stated-but-unrun rate as observed would let charge-rate charge for a move +// nobody was paying either side of. +func (c *chargeComp) rateAt(k ckey, other *chargeComp) Micro { + if r, ok := c.r[k]; ok { + return r + } + return other.r[k] +} + +// unionCKeys returns every line present in either dimension, canonically +// ordered. +func unionCKeys(a, b *chargeComp) []ckey { + seen := map[ckey]bool{} + out := make([]ckey, 0, len(a.keys)+len(b.keys)) + for _, src := range [][]ckey{a.keys, b.keys} { + for _, k := range src { + if !seen[k] { + seen[k] = true + out = append(out, k) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].less(out[j]) }) + return out +} + +// unionKinds returns every charge kind present in either dimension, sorted. +func unionKinds(keys []ckey) []string { + var out []string + for _, k := range keys { + if n := len(out); n == 0 || out[n-1] != k.Kind { + out = append(out, k.Kind) + } + } + return out +} + +// kindAt returns (units, Σ u·rate) for one kind of composition c, priced at +// `rates`' rates, iterating in canonical key order. +func kindAt(c *chargeComp, keys []ckey, kind string, rates, other *chargeComp) (int64, float64) { + var units int64 + var parts []float64 + for _, k := range keys { + if k.Kind != kind { + continue + } + units += c.u[k] + parts = append(parts, float64(c.u[k])*float64(rates.rateAt(k, other))) + } + return units, sumSorted(parts) +} + +// decomposeCharges peels charge-volume, charge-kind-mix, charge-class-mix and +// charge-rate off the charges change, in that order, and returns them as the +// sub-attributions of a single `charges` term whose value is the exact +// integer difference of the two supplied dimensions. +// +// The remainder — parent minus the four — is emitted as charge-unattributed +// so the identity `sum(Of) == parent` is exact by construction rather than by +// luck. In a correct decomposition it holds nothing but the three +// quantizations, and a value larger than that raises a note. +func decomposeCharges(a, b *chargeComp, anchors []ID, events []evidence.EvidenceEvent, att *Attribution) (Term, error) { + keys := unionCKeys(a, b) + kinds := unionKinds(keys) + UA, UB := float64(a.units), float64(b.units) + + parent, err := sub(b.total, a.total) + if err != nil { + return Term{}, err + } + + // charge-rate is exact integer arithmetic: end-of-window units times the + // per-line rate move (Paasche). + var rate Micro + var moved []string + rateMoved := map[ckey]bool{} + for _, k := range keys { + ra, rb := a.rateAt(k, b), b.rateAt(k, a) + if ra == rb { + continue + } + rateMoved[k] = true + d, err := sub(rb, ra) + if err != nil { + return Term{}, err + } + part, err := mul(d, b.u[k]) + if err != nil { + return Term{}, err + } + if rate, err = add(rate, part); err != nil { + return Term{}, err + } + if b.u[k] > 0 { + moved = append(moved, fmt.Sprintf("%s %s→%s", k, formatUSD(ra.USD()), formatUSD(rb.USD()))) + } + } + + var rbarA float64 + var volume, kindMix, classMix Micro + var volumeFacts []Fact + kindMoved := map[string]bool{} + classMoved := map[ckey]bool{} + + switch { + case a.units == 0: + // No prior mix to hold constant. Everything in the volume+mix bracket + // is measured at the end mix and start rates and reported as volume + // growth from nothing — the alternative would be a kind/class-mix + // term describing a change from a line-item set that did not exist. + var parts []float64 + for _, k := range keys { + parts = append(parts, float64(b.u[k])*float64(a.rateAt(k, b))) + } + startPriced := sumSorted(parts) + if volume, err = quantize(startPriced); err != nil { + return Term{}, err + } + if b.units > 0 { + rbarA = startPriced / UB + att.Notes = append(att.Notes, + "the window opens with no non-node charges, so there is no prior charge mix to hold constant: the whole charge change is reported as charge-volume and the mix terms are zero by construction") + } + volumeFacts = []Fact{ + {"fromUnits", "0"}, + {"toUnits", strconv.FormatInt(b.units, 10)}, + {"referenceUnitUSDPerHour", formatUSD(rbarA / MicroPerUSD)}, + {"note", "priced at the end-of-window charge mix; no start mix existed"}, + } + default: + rbarA = float64(a.total) / UA + + // 1. charge-volume: the unit change at the START mix and START rates. + if volume, err = quantize((UB - UA) * rbarA); err != nil { + return Term{}, err + } + + var kindParts, classParts []float64 + for _, j := range kinds { + uA, costA := kindAt(a, keys, j, a, b) + uB, costBA := kindAt(b, keys, j, a, b) + + var sigA, sigB, qA, qBA float64 + if UA > 0 { + sigA = float64(uA) / UA + } + if UB > 0 { + sigB = float64(uB) / UB + } + if uA > 0 { + qA = costA / float64(uA) + } + if uB > 0 { + qBA = costBA / float64(uB) + } + // A kind absent at one edge has no average rate on that side. + // The identity below holds for ANY finite value here — qA + // cancels between the two terms — so the choice is purely about + // which term tells the truth. Borrowing the other edge's average + // (still at START rates) keeps "you moved a third of the fleet + // onto Fargate" inside charge-kind-mix rather than splitting it + // across kind-mix and an offsetting class-mix that describes no + // class change at all. + if uA == 0 { + qA = qBA + } + if uB == 0 { + qBA = qA + } + if sigA != sigB { + kindMoved[j] = true + } + // 2. charge-kind-mix: kind shares move, within-kind class mix + // held at the start, rates held at the start. + kindParts = append(kindParts, UB*(sigB-sigA)*qA) + // 3. charge-class-mix: within-kind class mix moves, at the end + // kind shares and start rates. + classParts = append(classParts, UB*sigB*(qBA-qA)) + + for _, k := range keys { + if k.Kind != j { + continue + } + var wA, wB float64 + if uA > 0 { + wA = float64(a.u[k]) / float64(uA) + } + if uB > 0 { + wB = float64(b.u[k]) / float64(uB) + } + if wA != wB { + classMoved[k] = true + } + } + } + if kindMix, err = quantize(sumSorted(kindParts)); err != nil { + return Term{}, err + } + if classMix, err = quantize(sumSorted(classParts)); err != nil { + return Term{}, err + } + volumeFacts = []Fact{ + {"fromUnits", strconv.FormatInt(a.units, 10)}, + {"toUnits", strconv.FormatInt(b.units, 10)}, + {"referenceUnitUSDPerHour", formatUSD(rbarA / MicroPerUSD)}, + {"source", "start charges (mix and rates held at the window start)"}, + } + } + + chained, err := sumMicro([]Micro{volume, kindMix, classMix, rate}) + if err != nil { + return Term{}, err + } + left, err := sub(parent, chained) + if err != nil { + return Term{}, err + } + if left > maxChargeQuantizationMicro || left < -maxChargeQuantizationMicro { + // Three quantizations cannot exceed 1 µUSD between them, so anything + // larger is a modelling error and must be visible rather than sitting + // mute inside a sub-term nobody reads. + att.Notes = append(att.Notes, fmt.Sprintf( + "the charge chain leaves %d µUSD/h unattributed, more than the %d µUSD/h that quantization can explain; treat the charge terms as approximate", + left, maxChargeQuantizationMicro)) + } + + unitsMoved := func(k ckey) bool { return a.u[k] != b.u[k] } + subs := []Term{ + newTerm(TermChargeVolume, chargeVolumeLabel(b.units-a.units, rbarA), volume, volumeFacts, + append(anchors, chargeEvidence(a, b, keys, unitsMoved)...)), + newTerm(TermChargeKindMix, chargeKindMixLabel(kindMix), kindMix, chargeKindFacts(a, b, keys, kinds), + append(anchors, chargeEvidence(a, b, keys, func(k ckey) bool { return kindMoved[k.Kind] })...)), + newTerm(TermChargeClassMix, chargeClassMixLabel(classMix), classMix, chargeClassFacts(a, b, keys), + append(anchors, chargeEvidence(a, b, keys, func(k ckey) bool { return classMoved[k] })...)), + newTerm(TermChargeRate, chargeRateLabel(rate, len(moved)), rate, chargeRateFacts(moved), + concatIDs(anchors, chargeEvidence(a, b, keys, func(k ckey) bool { return rateMoved[k] }), + eventIDs(events, evidence.EventPricingChange))), + newTerm(TermChargeUnattributed, chargeUnattributedLabel(left), left, + []Fact{{"method", "the charge change the chain above does not account for; in a correct decomposition this is the three quantizations and nothing else"}}, + anchors), + } + + parentFacts := []Fact{ + {"fromUSDPerHour", formatUSD(a.total.USD())}, + {"toUSDPerHour", formatUSD(b.total.USD())}, + {"fromUnits", strconv.FormatInt(a.units, 10)}, + {"toUnits", strconv.FormatInt(b.units, 10)}, + {"fromLines", strconv.Itoa(len(a.keys))}, + {"toLines", strconv.Itoa(len(b.keys))}, + {"kinds", joinCapped(kinds, 8)}, + {"order", strings.Join(chargeChainOrder, " → ")}, + } + parentTerm := newTerm(TermCharges, chargesLabel(parent, kinds), parent, parentFacts, + concatIDs(anchors, chargeEvidence(a, b, keys, func(ckey) bool { return true }))) + // A charges dimension with no line at either edge is a real claim — "we + // looked, there was nothing" — but it has no chain to show. Emitting four + // zero sub-terms and a zero remainder would be five rows of ceremony + // around a number that is zero for a stated reason. + if len(keys) > 0 { + parentTerm.Of = subs + } + return parentTerm, nil +} + +// maxChargeQuantizationMicro bounds what charge-unattributed may hold in a +// correct decomposition. charge-volume, charge-kind-mix and charge-class-mix +// are each quantized once, losing under half a µUSD; charge-rate and the +// parent are exact integer arithmetic. Three half-µUSD errors sum to under +// 1.5, and the remainder is an integer, so 1 is the true ceiling — 2 is the +// same slack the node side allows itself (TestChargeQuantizationBoundMatches +// TheNodeSide fails if the two ever drift). +const maxChargeQuantizationMicro = Micro(2) + +// chargeEvidence collects the citations of every line matching pred, from +// both edges, in canonical key order. newTerm sorts, de-duplicates and caps +// what it returns, so the order here is about determinism, not presentation. +func chargeEvidence(a, b *chargeComp, keys []ckey, pred func(ckey) bool) []ID { + var out []ID + for _, k := range keys { + if !pred(k) { + continue + } + out = append(out, a.ev[k]...) + out = append(out, b.ev[k]...) + } + return out +} + +// chargeKindFacts names each kind's unit share at both edges, in canonical +// order — the numbers the kind-mix term is computed from. +func chargeKindFacts(a, b *chargeComp, keys []ckey, kinds []string) []Fact { + facts := make([]Fact, 0, len(kinds)) + for _, j := range kinds { + uA, _ := kindAt(a, keys, j, a, b) + uB, _ := kindAt(b, keys, j, a, b) + facts = append(facts, Fact{j, fmt.Sprintf("%s → %s of units (%d → %d)", + formatRatio(share(uA, a.units)), formatRatio(share(uB, b.units)), uA, uB)}) + if len(facts) == 8 { + break + } + } + return facts +} + +// chargeClassFacts names the biggest movers in the class mix, largest +// absolute unit change first, ties broken by canonical key order. +func chargeClassFacts(a, b *chargeComp, keys []ckey) []Fact { + type mover struct { + k ckey + d int64 + } + var ms []mover + for _, k := range keys { + if d := b.u[k] - a.u[k]; d != 0 { + ms = append(ms, mover{k, d}) + } + } + sort.SliceStable(ms, func(i, j int) bool { + ai, aj := abs64(ms[i].d), abs64(ms[j].d) + if ai != aj { + return ai > aj + } + return ms[i].k.less(ms[j].k) + }) + if len(ms) > 6 { + ms = ms[:6] + } + facts := make([]Fact, 0, len(ms)) + for _, m := range ms { + facts = append(facts, Fact{m.k.String(), fmt.Sprintf("%+d units", m.d)}) + } + return facts +} + +func chargeRateFacts(moved []string) []Fact { + facts := []Fact{{"linesRepriced", strconv.Itoa(len(moved))}} + if len(moved) > 0 { + sort.Strings(moved) + facts = append(facts, Fact{"moved", joinCapped(moved, 8)}) + } + return facts +} + +// --- labels. Template text over numbers computed above; every quantity in a +// label is already a field of the term it labels (see prose.go). + +func chargesLabel(m Micro, kinds []string) string { + if len(kinds) == 0 { + return "no non-node charges at either window edge" + } + list := joinCapped(kinds, 3) + switch { + case m > 0: + return "non-node charges (" + list + ") cost more" + case m < 0: + return "non-node charges (" + list + ") cost less" + default: + return "non-node charges (" + list + ") did not move the bill" + } +} + +func chargeVolumeLabel(deltaUnits int64, rbarMicro float64) string { + switch { + case deltaUnits > 0: + return fmt.Sprintf("%d more charged %s at the window's starting average rate of %s/unit-hour", + deltaUnits, plural(deltaUnits, "unit", "units"), formatUSD(rbarMicro/MicroPerUSD)) + case deltaUnits < 0: + return fmt.Sprintf("%d fewer charged %s at the window's starting average rate of %s/unit-hour", + -deltaUnits, plural(deltaUnits, "unit", "units"), formatUSD(rbarMicro/MicroPerUSD)) + default: + return "charged unit count unchanged" + } +} + +func chargeKindMixLabel(m Micro) string { + switch { + case m < 0: + return "the charge mix shifted toward cheaper kinds of charge" + case m > 0: + return "the charge mix shifted toward more expensive kinds of charge" + default: + return "the mix of charge kinds did not move the bill" + } +} + +func chargeClassMixLabel(m Micro) string { + switch { + case m < 0: + return "charged units shifted toward cheaper configurations" + case m > 0: + return "charged units shifted toward more expensive configurations" + default: + return "the configuration mix did not move the bill" + } +} + +func chargeRateLabel(m Micro, lines int) string { + if lines == 0 { + return "no rate changed for any charge line still running" + } + dir := "rose" + if m < 0 { + dir = "fell" + } + if m == 0 { + dir = "moved, netting out" + } + return fmt.Sprintf("charge rates %s for %d %s", dir, lines, plural(int64(lines), "line", "lines")) +} + +func chargeUnattributedLabel(m Micro) string { + if m == 0 { + return "the charge chain accounts for every µUSD of the charge change" + } + if m <= maxChargeQuantizationMicro && m >= -maxChargeQuantizationMicro { + return "rounding left over by the charge share arithmetic" + } + return "charge change the chain does not account for" +} diff --git a/pkg/explain/charges_fuzz_test.go b/pkg/explain/charges_fuzz_test.go new file mode 100644 index 0000000..f1abcc5 --- /dev/null +++ b/pkg/explain/charges_fuzz_test.go @@ -0,0 +1,308 @@ +package explain + +import ( + "encoding/json" + "testing" + "time" + + "github.com/agenticode/kilter/pkg/evidence" +) + +var fuzzChargeKinds = []string{"fargate", "ebs", "lambda"} + +// The empty class is deliberate: a kind whose charge has no sub-shape (a flat +// line item) must decompose as cleanly as Fargate's configuration tiers. +var fuzzChargeClasses = []string{"0.25vCPU/0.5GB", "1vCPU/2GB", "4vCPU/8GB", ""} + +// charges generates one edge's charges dimension. Unlike the node generator +// it emits duplicate (kind, class) rows freely: the charge identity is +// computed from the *merged* dimension, so duplicates exercise the merge rule +// from inside the invariant rather than having to be excluded from it. +func (r *bits) charges(at time.Time) ([]Charge, bool) { + if !r.bool() { + return nil, false // this edge does not know its charges + } + cite := TimelineID(testCluster, evidence.TimelinePoint{At: at}) + var out []Charge + for n := r.intn(7); n > 0; n-- { + out = append(out, Charge{ + Kind: fuzzChargeKinds[r.intn(len(fuzzChargeKinds))], + Class: fuzzChargeClasses[r.intn(len(fuzzChargeClasses))], + Units: int64(r.intn(500)), + UnitUSDPerHour: r.cents(1000), + Evidence: []ID{cite}, + }) + } + return out, true +} + +// modelledCost reprices a whole basis — fleet plus charges — the way WhyCost +// does, by folding it through the same canonical forms. Used to build the +// *closed* case: an input the supplied evidence fully accounts for, where the +// residual must therefore be quantization and nothing else. +func modelledCost(t *testing.T, b *CostBasis) (Micro, int64) { + t.Helper() + c, err := newComp(b) + if err != nil { + t.Fatalf("newComp: %v", err) + } + ch, err := newChargeComp(b) + if err != nil { + t.Fatalf("newChargeComp: %v", err) + } + total, err := add(c.total, ch.total) + if err != nil { + t.Fatalf("add: %v", err) + } + return total, c.nodes +} + +// FuzzChargeInvariants is the acceptance property for the charges dimension. +// It asserts, over arbitrary fleets, charge sets, ledgers and timelines, that +// BOTH arithmetic identities hold exactly and that no rounding path can make +// either one fail: +// +// sum(Terms) + Residual == Delta (unchanged) +// charges.Micro == ChargeDelta == Σ(end u·r) − Σ(start u·r) (new) +// sum(charges.Of) == charges.Micro (new) +// +// plus the assertion that carries the actual weight, because the first three +// are true by construction in isolation: when the supplied evidence fully +// prices the observed cost, the charge chain's own remainder is quantization +// only. A decomposition that failed to explain a charge move would still +// satisfy the identities — charge-unattributed would silently swallow the +// difference — and this is what catches that. +func FuzzChargeInvariants(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{1}) + f.Add([]byte{1, 1, 3, 0, 0, 40, 12, 1, 1, 5, 2, 2, 8, 60, 1, 3, 9, 4, 4, 4, 7, 7}) + f.Add([]byte{3, 1, 200, 99, 1, 6, 2, 1, 90, 0, 0, 12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}) + f.Add([]byte{0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1}) + f.Add(make([]byte, 96)) + + f.Fuzz(func(t *testing.T, data []byte) { + r := &bits{b: data} + from := t0 + to := t0.Add(hours(1 + r.intn(400))) + endAt := to.Add(-time.Minute) + closed := r.bool() + + start, end := r.basis(from), r.basis(endAt) + start.Charges, start.ChargesKnown = r.charges(from) + end.Charges, end.ChargesKnown = r.charges(endAt) + + var costA, costB float64 + var nodesA, nodesB int + if closed { + // Fleet AND charges price the observed cost exactly: the model is + // complete in both dimensions, so nothing but quantization may be + // left anywhere. + // + // Both edges must state their charges for that to be true, and + // the fuzzer found out why: a dimension stated at one edge only + // is deliberately NOT supplied evidence — WhyCost refuses it and + // leaves the move in the residual — so a "closed" case built on + // one is asserting that the package attributed something it is + // designed to decline. The asymmetric shape is still generated + // and still asserted, in the open branch below. + start.ChargesKnown, end.ChargesKnown = true, true + var ma, mb Micro + var na, nb int64 + ma, na = modelledCost(t, start) + mb, nb = modelledCost(t, end) + costA, costB = ma.USD(), mb.USD() + nodesA, nodesB = int(na), int(nb) + } else { + costA, costB = r.cents(20000), r.cents(20000) + nodesA, nodesB = r.intn(500), r.intn(500) + } + + in := Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{ + point(from, costA, nodesA), + point(from.Add(to.Sub(from)/2), (costA+costB)/2, (nodesA+nodesB)/2), + point(endAt, costB, nodesB), + }, + Start: start, End: end, + } + for n := r.intn(3); n > 0; n-- { + in.Actions = append(in.Actions, LedgerAction{ + At: from.Add(time.Duration(r.intn(int(to.Sub(from)/time.Hour)+1)) * time.Hour), + Cluster: testCluster, + Fingerprint: string(rune('a' + r.intn(26))), + Mode: "apply", + Applied: true, + NodesRemoved: int64(r.intn(20)), + }) + } + for n := r.intn(3); n > 0; n-- { + in.Events = append(in.Events, ev( + from.Add(time.Duration(r.intn(24))*time.Hour), + []string{evidence.EventPricingChange, evidence.EventSpotInterrupt, evidence.EventDeploy}[r.intn(3)], + evidence.SeverityInfo, + workloadSubject(fuzzNamespaces[r.intn(len(fuzzNamespaces))], "app"), nil)) + } + + a, err := WhyCost(in) + if err != nil { + // Every generated input is inside the documented bounds and every + // charge is citable, so there is no acceptable failure here. + t.Fatalf("WhyCost: %v", err) + } + + // 1. The central invariant, exactly. Adding a dimension may not cost + // the package the identity it was built around. + sum, err := a.Sum() + if err != nil { + t.Fatalf("Sum: %v", err) + } + if sum != a.DeltaMicro { + t.Fatalf("sum(terms)+residual = %d, ΔCost = %d (off by %d)", sum, a.DeltaMicro, sum-a.DeltaMicro) + } + + bothKnown := start.ChargesKnown && end.ChargesKnown + if a.ChargesKnown != bothKnown { + t.Fatalf("ChargesKnown = %v, want %v (start %v, end %v)", + a.ChargesKnown, bothKnown, start.ChargesKnown, end.ChargesKnown) + } + parent, hasCharges := termByKind(a, TermCharges) + if hasCharges != bothKnown { + t.Fatalf("a %q term is present = %v but the dimension is known at both edges = %v", + TermCharges, hasCharges, bothKnown) + } + + if bothKnown { + // 2. The charges term is the exact repriced difference of the two + // supplied dimensions — the identity that stops the third from + // being vacuous. + chA, err := newChargeComp(start) + if err != nil { + t.Fatalf("newChargeComp(start): %v", err) + } + chB, err := newChargeComp(end) + if err != nil { + t.Fatalf("newChargeComp(end): %v", err) + } + want, err := sub(chB.total, chA.total) + if err != nil { + t.Fatalf("sub: %v", err) + } + if a.ChargeFromMicro != chA.total || a.ChargeToMicro != chB.total { + t.Fatalf("charge endpoints are %d → %d µUSD/h, want %d → %d", + a.ChargeFromMicro, a.ChargeToMicro, chA.total, chB.total) + } + if a.ChargeDeltaMicro != want || parent.Micro != want { + t.Fatalf("charges term = %d and ChargeDelta = %d, want %d", parent.Micro, a.ChargeDeltaMicro, want) + } + + // 3. The chain reconstructs its parent exactly, one level deep. + if len(parent.Of) > 0 { + var subSum Micro + var left Micro + var sawLeft bool + for _, s := range parent.Of { + if len(s.Of) != 0 { + t.Fatalf("charge sub-term %q nests further", s.Kind) + } + subSum += s.Micro + if s.Kind == TermChargeUnattributed { + left, sawLeft = s.Micro, true + } + } + if subSum != parent.Micro { + t.Fatalf("charge sub-terms sum to %d, parent is %d (off by %d)", subSum, parent.Micro, subSum-parent.Micro) + } + if !sawLeft { + t.Fatalf("the charge chain shipped without an explicit %q remainder", TermChargeUnattributed) + } + // 4. No rounding path may push the remainder past what + // quantization can explain. This is the assertion that + // would catch an incomplete chain. + if left > maxChargeQuantizationMicro || left < -maxChargeQuantizationMicro { + t.Fatalf("%s holds %d µUSD/h; three quantizations cannot exceed %d, so the charge chain is incomplete, not merely rounded", + TermChargeUnattributed, left, maxChargeQuantizationMicro) + } + } + + // 5. The stated order is the emitted order, and charges is its + // last link. + var kinds []string + for _, term := range a.Terms { + kinds = append(kinds, term.Kind) + } + if !equalStrings(kinds, a.Order) { + t.Fatalf("terms are emitted as %v but Order states %v", kinds, a.Order) + } + if a.Order[len(a.Order)-1] != TermCharges { + t.Fatalf("Order ends with %q, want %q", a.Order[len(a.Order)-1], TermCharges) + } + if !equalStrings(a.ChargeOrder, chargeChainOrder) { + t.Fatalf("ChargeOrder = %v, want %v", a.ChargeOrder, chargeChainOrder) + } + } else if a.ChargeDeltaMicro != 0 || a.ChargeFromMicro != 0 || a.ChargeToMicro != 0 || len(a.ChargeOrder) != 0 { + // A half-known dimension must leave no trace but a note: reading + // the silent edge as zero is the failure this refuses. + t.Fatalf("charge fields are populated without both edges: from=%d to=%d delta=%d order=%v", + a.ChargeFromMicro, a.ChargeToMicro, a.ChargeDeltaMicro, a.ChargeOrder) + } + + // 6. Nothing uncitable ships, charges included. + for _, term := range append(append([]Term(nil), a.Terms...), a.Residual) { + for _, s := range append([]Term{term}, term.Of...) { + if len(s.Evidence) == 0 { + t.Fatalf("term %q carries no evidence id", s.Kind) + } + for _, id := range s.Evidence { + if _, err := Parse(id); err != nil { + t.Fatalf("term %q emitted an unparseable id %q: %v", s.Kind, id, err) + } + } + } + } + + // 7. A complete model — both dimensions — leaves only rounding behind. + if closed && (a.Residual.Micro > maxQuantizationMicro || a.Residual.Micro < -maxQuantizationMicro) { + t.Fatalf("a fleet and a charge set that fully price the observed cost left a residual of %d µUSD/h; the decomposition is incomplete, not merely rounded", + a.Residual.Micro) + } + + // 8. Determinism: reversing every input slice, charge rows included, + // changes nothing. + want, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + rev := in + rev.Timeline = reversedPoints(in.Timeline) + rev.Events = reversedEvents(in.Events) + rev.Actions = reversedActions(in.Actions) + rev.Start = reversedChargeBasis(in.Start) + rev.End = reversedChargeBasis(in.End) + b, err := WhyCost(rev) + if err != nil { + t.Fatalf("WhyCost(reversed): %v", err) + } + got, err := json.Marshal(b) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != string(want) { + t.Fatalf("reversing the inputs changed the answer\n got: %s\nwant: %s", got, want) + } + }) +} + +// reversedChargeBasis is reversedBasis with the charges dimension reversed +// too, so the shuffle covers both dimensions of one basis. +func reversedChargeBasis(in *CostBasis) *CostBasis { + out := reversedBasis(in) + if out == nil { + return nil + } + out.ChargesKnown = in.ChargesKnown + for i := len(in.Charges) - 1; i >= 0; i-- { + out.Charges = append(out.Charges, in.Charges[i]) + } + return out +} diff --git a/pkg/explain/charges_test.go b/pkg/explain/charges_test.go new file mode 100644 index 0000000..1af4937 --- /dev/null +++ b/pkg/explain/charges_test.go @@ -0,0 +1,779 @@ +package explain + +import ( + "encoding/json" + "math" + "math/rand" + "strings" + "testing" + "time" + + "github.com/agenticode/kilter/pkg/evidence" +) + +// anchorID is the citation every fixture charge carries: the timeline point +// the charge was read from, which is exactly the standard the node terms hold +// themselves to. +func anchorID(at time.Time) ID { return TimelineID(testCluster, point(at, 0, 0)) } + +// charge builds a citable fixture charge, failing the test rather than +// returning an uncitable one — the constructor is the contract under test in +// TestChargeCannotBeConstructedUncited, not a convenience here. +func charge(t *testing.T, at time.Time, kind, class string, units int64, rate float64) Charge { + t.Helper() + c, err := NewCharge(kind, class, units, rate, anchorID(at)) + if err != nil { + t.Fatalf("NewCharge(%s/%s): %v", kind, class, err) + } + return c +} + +// fargateInput is the worked example this file's arithmetic follows, and it +// is deliberately node-flat: the fleet costs $1.00/h at both edges, so every +// dollar of the observed move is a charge move and nothing can hide in a node +// term. +// +// start: 8 pods on 0.25vCPU/0.5GB at $0.012500/h = $0.100000/h +// 2 pods on 1vCPU/2GB at $0.050000/h = $0.100000/h +// 10 units, $0.200000/h +// end: 4 pods on 0.25vCPU/0.5GB at $0.012500/h = $0.050000/h +// 8 pods on 1vCPU/2GB at $0.055000/h = $0.440000/h +// 12 units, $0.490000/h +// +// Observed cost therefore moves $1.20/h → $1.49/h. +func fargateInput(t *testing.T) Input { + t.Helper() + from, to := t0, t0.Add(hours(24)) + end := to.Add(-time.Minute) + return Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{point(from, 1.20, 10), point(end, 1.49, 10)}, + Start: &CostBasis{ + At: from, + Groups: []NodeGroup{{InstanceType: "m5.large", Nodes: 10, UnitUSDPerHour: 0.10}}, + ChargesKnown: true, + Charges: []Charge{ + charge(t, from, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + charge(t, from, ChargeKindFargate, "1vCPU/2GB", 2, 0.05), + }, + }, + End: &CostBasis{ + At: end, + Groups: []NodeGroup{{InstanceType: "m5.large", Nodes: 10, UnitUSDPerHour: 0.10}}, + ChargesKnown: true, + Charges: []Charge{ + charge(t, end, ChargeKindFargate, "0.25vCPU/0.5GB", 4, 0.0125), + charge(t, end, ChargeKindFargate, "1vCPU/2GB", 8, 0.055), + }, + }, + } +} + +// chargeSub returns one sub-attribution of the charges term. +func chargeSub(t *testing.T, a *Attribution, kind string) Term { + t.Helper() + parent := mustTerm(t, a, TermCharges) + for _, s := range parent.Of { + if s.Kind == kind { + return s + } + } + t.Fatalf("no %q sub-attribution of %q; got %d children", kind, TermCharges, len(parent.Of)) + return Term{} +} + +// checkChargeSums is the charges dimension's identity, asserted on every +// attribution this file produces. It is the same shape as checkSums and is +// deliberately independent of Attribution.check, so a bug in check cannot +// make the tests agree with it. +func checkChargeSums(t *testing.T, a *Attribution) { + t.Helper() + parent, ok := termByKind(a, TermCharges) + if !a.ChargesKnown { + if ok { + t.Errorf("a %q term shipped without charges at both edges", TermCharges) + } + return + } + if !ok { + t.Fatalf("charges are known but no %q term was emitted", TermCharges) + } + if want := a.ChargeToMicro - a.ChargeFromMicro; parent.Micro != want { + t.Errorf("%q = %d µUSD/h, want the exact repriced difference %d", TermCharges, parent.Micro, want) + } + if parent.Micro != a.ChargeDeltaMicro { + t.Errorf("%q = %d µUSD/h but ChargeDeltaMicro is %d", TermCharges, parent.Micro, a.ChargeDeltaMicro) + } + if len(parent.Of) == 0 { + return + } + var sum Micro + for _, s := range parent.Of { + sum += s.Micro + } + if sum != parent.Micro { + t.Errorf("charge sub-terms sum to %d µUSD/h, parent is %d", sum, parent.Micro) + } + if left := chargeSub(t, a, TermChargeUnattributed); left.Micro > maxChargeQuantizationMicro || left.Micro < -maxChargeQuantizationMicro { + t.Errorf("%s = %d µUSD/h, above the %d µUSD/h quantization can explain: the chain is incomplete, not merely rounded", + TermChargeUnattributed, left.Micro, maxChargeQuantizationMicro) + } +} + +// TestChargesWorkedExample pins the arithmetic by hand. A decomposition +// nobody can check by hand is not an audit artifact. +func TestChargesWorkedExample(t *testing.T) { + a := mustWhy(t, fargateInput(t)) + checkSums(t, a) + checkChargeSums(t, a) + checkCitable(t, a) + + if a.ChargeFromMicro != 200_000 || a.ChargeToMicro != 490_000 { + t.Fatalf("charge endpoints: from=%d to=%d µUSD/h, want 200000 and 490000", a.ChargeFromMicro, a.ChargeToMicro) + } + if a.ChargeDeltaMicro != 290_000 { + t.Fatalf("Δcharges = %d µUSD/h, want 290000", a.ChargeDeltaMicro) + } + want := map[string]Micro{ + // (12−10) units × $0.020000 start average rate. + TermChargeVolume: 40_000, + // Only one kind exists, so no kind share can move. + TermChargeKindMix: 0, + // 12 × [(0.25·12500 + 0.75·50000) − 20000] µUSD: the mix moved from + // 80/20 onto 33/67 at START rates. + TermChargeClassMix: 210_000, + // 8 surviving 1vCPU/2GB units × ($0.055−$0.050). + TermChargeRate: 40_000, + // 40000 + 0 + 210000 + 40000 = 290000, exactly. + TermChargeUnattributed: 0, + } + for kind, v := range want { + if got := chargeSub(t, a, kind); got.Micro != v { + t.Errorf("charge sub-term %s = %d µUSD/h, want %d", kind, got.Micro, v) + } + } + // The fleet is flat and fully priced, so the whole ΔCost is the charge + // move and nothing is left over. + if a.Residual.Micro != 0 { + t.Errorf("residual = %d µUSD/h, want 0: composition plus charges price the observed cost exactly", a.Residual.Micro) + } + for _, kind := range []string{TermNodeCount, TermSpotRatio, TermInstanceMix, TermPricingCatalog} { + if got := mustTerm(t, a, kind); got.Micro != 0 { + t.Errorf("term %s = %d µUSD/h; the fleet did not move, so it must be zero", kind, got.Micro) + } + } +} + +// TestChargesShrinkTheResidualTheyUsedToBe is the whole point of the unit, +// stated as a before/after: the same cluster, the same meter, with and +// without the charges dimension supplied. Every µUSD that leaves the residual +// must land in the charges term — not merely leave. +func TestChargesShrinkTheResidualTheyUsedToBe(t *testing.T) { + with := fargateInput(t) + without := fargateInput(t) + without.Start.Charges, without.Start.ChargesKnown = nil, false + without.End.Charges, without.End.ChargesKnown = nil, false + + before := mustWhy(t, without) + after := mustWhy(t, with) + checkSums(t, before) + checkSums(t, after) + checkChargeSums(t, after) + + if before.Residual.Micro != 290_000 { + t.Fatalf("without charges the residual is %d µUSD/h, want the whole unattributed 290000", before.Residual.Micro) + } + if after.Residual.Micro != 0 { + t.Fatalf("with charges the residual is %d µUSD/h, want 0", after.Residual.Micro) + } + moved := before.Residual.Micro - after.Residual.Micro + if got := mustTerm(t, after, TermCharges); got.Micro != moved { + t.Errorf("%d µUSD/h left the residual but the %q term is %d; a dollar may only leave the residual by landing in a term that claims it", + moved, TermCharges, got.Micro) + } + // Nothing else may have moved to make room. + for _, kind := range []string{TermNodeCount, TermSpotRatio, TermInstanceMix, TermPricingCatalog} { + b, a2 := mustTerm(t, before, kind), mustTerm(t, after, kind) + if b.Micro != a2.Micro { + t.Errorf("term %s changed from %d to %d µUSD/h when charges were supplied; the node chain must be untouched", + kind, b.Micro, a2.Micro) + } + } +} + +// TestResidualStillGrowsWhenChargesCannotExplainIt is the complement of the +// test above, and the one that keeps the dimension honest: a decomposition +// that can only ever shrink the residual is not measuring, it is asserting. +// +// Here the supplied charges are flat — Fargate genuinely did not move — and +// the meter says the bill rose by $0.29/h anyway. The charges term must be +// zero and the residual must hold the whole unexplained amount. +func TestResidualStillGrowsWhenChargesCannotExplainIt(t *testing.T) { + in := fargateInput(t) + in.End.Charges = []Charge{ + charge(t, in.End.At, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + charge(t, in.End.At, ChargeKindFargate, "1vCPU/2GB", 2, 0.05), + } + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + + if got := mustTerm(t, a, TermCharges); got.Micro != 0 { + t.Fatalf("%q = %d µUSD/h, want 0: no charge line moved", TermCharges, got.Micro) + } + for _, s := range mustTerm(t, a, TermCharges).Of { + if s.Micro != 0 { + t.Errorf("charge sub-term %s = %d µUSD/h; nothing moved, so every one must be zero", s.Kind, s.Micro) + } + } + if a.Residual.Micro != 290_000 { + t.Fatalf("residual = %d µUSD/h, want the whole unexplained 290000", a.Residual.Micro) + } + if !strings.Contains(strings.Join(a.Notes, " "), "unpriced") { + t.Errorf("expected a note naming the unpriced gap, got %v", a.Notes) + } +} + +// TestChargesAtOneEdgeAreRefusedNotZeroed: a missing edge is not a zero. +// Reading it as one would report an entire Fargate bill as having appeared +// out of nothing — a large, confident, wrong number where an admission +// belonged. +func TestChargesAtOneEdgeAreRefusedNotZeroed(t *testing.T) { + in := fargateInput(t) + in.Start.Charges, in.Start.ChargesKnown = nil, false + + a := mustWhy(t, in) + checkSums(t, a) + checkCitable(t, a) + + if a.ChargesKnown { + t.Error("ChargesKnown is set although only one edge stated its charges") + } + if _, ok := termByKind(a, TermCharges); ok { + t.Fatalf("a %q term was emitted from a single edge", TermCharges) + } + if len(a.ChargeOrder) != 0 || a.ChargeDeltaMicro != 0 { + t.Errorf("charge fields are populated without a two-edge dimension: order=%v delta=%d", a.ChargeOrder, a.ChargeDeltaMicro) + } + // $1.20 observed against $1.00 of fleet at the start, $1.49 against $1.00 + // at the end: the whole $0.29 charge move stays unattributed. + if a.Residual.Micro != 290_000 { + t.Fatalf("residual = %d µUSD/h, want 290000", a.Residual.Micro) + } + joined := strings.Join(a.Notes, " ") + if !strings.Contains(joined, "a missing edge is not a zero") { + t.Errorf("expected a note refusing the one-sided dimension, got %v", a.Notes) + } +} + +// TestChargeAttributionOrderIsTheDocumentedOne pins the choice charges.go +// argues for. charge-volume is measured at the START class mix; the equally +// defensible "class mix first" convention would measure it at the END mix and +// produce a different number. +func TestChargeAttributionOrderIsTheDocumentedOne(t *testing.T) { + a := mustWhy(t, fargateInput(t)) + vol := chargeSub(t, a, TermChargeVolume) + + // Documented: ΔU × (start charge cost / start units) = 2 × $0.020000. + const startMixAnswer = Micro(40_000) + // The alternative: ΔU × (end mix priced at start rates / end units) + // = 2 × ((4×$0.0125 + 8×$0.05)/12) = 2 × $0.0375. + const endMixAnswer = Micro(75_000) + + if vol.Micro != startMixAnswer { + t.Fatalf("charge-volume = %d µUSD/h, want %d (measured at the START charge mix, per charges.go)", + vol.Micro, startMixAnswer) + } + if startMixAnswer == endMixAnswer { + t.Fatal("fixture is useless: both conventions agree, so it proves nothing about the order") + } + if want := chargeChainOrder; !equalStrings(a.ChargeOrder, want) { + t.Errorf("ChargeOrder = %v, want %v", a.ChargeOrder, want) + } + // The emitted sub-term order is the chain order, so a reader of the JSON + // sees the convention in the sequence as well as in the field. + var kinds []string + for _, s := range mustTerm(t, a, TermCharges).Of { + kinds = append(kinds, s.Kind) + } + if want := append(append([]string(nil), chargeChainOrder...), TermChargeUnattributed); !equalStrings(kinds, want) { + t.Errorf("charge sub-terms are emitted as %v, want %v", kinds, want) + } + // And charges is the last link of the top-level chain, so Order still + // describes the terms in the order they appear. + var top []string + for _, term := range a.Terms { + top = append(top, term.Kind) + } + if !equalStrings(top, a.Order) { + t.Errorf("terms are emitted as %v, want the stated order %v", top, a.Order) + } + if last := a.Order[len(a.Order)-1]; last != TermCharges { + t.Errorf("Order ends with %q, want %q", last, TermCharges) + } +} + +// TestChargeKindMixIsThePolicyTerm pins the second half of the order +// argument: moving units between *kinds* is a placement policy and belongs in +// charge-kind-mix, priced at the old within-kind class mix. Half the Fargate +// pods move to a cheaper kind at the same unit count, so volume is zero and +// the whole move is the policy term. +func TestChargeKindMixIsThePolicyTerm(t *testing.T) { + from, to := t0, t0.Add(hours(24)) + end := to.Add(-time.Minute) + in := Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{point(from, 0.40, 0), point(end, 0.30, 0)}, + Start: &CostBasis{At: from, ChargesKnown: true, Charges: []Charge{ + charge(t, from, ChargeKindFargate, "1vCPU/2GB", 8, 0.05), + }}, + End: &CostBasis{At: end, ChargesKnown: true, Charges: []Charge{ + charge(t, end, ChargeKindFargate, "1vCPU/2GB", 4, 0.05), + charge(t, end, "ebs", "gp3", 4, 0.025), + }}, + } + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + + if got := chargeSub(t, a, TermChargeVolume).Micro; got != 0 { + t.Errorf("charge-volume = %d µUSD/h, want 0: the unit count did not move", got) + } + // 8 × [(0.5−1)×50000 + (0.5−0)×25000] = −100000. + if got := chargeSub(t, a, TermChargeKindMix).Micro; got != -100_000 { + t.Errorf("charge-kind-mix = %d µUSD/h, want -100000", got) + } + if got := chargeSub(t, a, TermChargeClassMix).Micro; got != 0 { + t.Errorf("charge-class-mix = %d µUSD/h, want 0: no class share moved inside a kind", got) + } + if got := chargeSub(t, a, TermChargeRate).Micro; got != 0 { + t.Errorf("charge-rate = %d µUSD/h, want 0: no rate moved", got) + } +} + +// TestChargeRateMeansRatesMoved: a line that appears mid-window borrows the +// other edge's rate, so it contributes a volume or mix change and never a +// rate change. Otherwise "rates rose" would mean "you bought something". +func TestChargeRateMeansRatesMoved(t *testing.T) { + from, to := t0, t0.Add(hours(24)) + end := to.Add(-time.Minute) + in := Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{point(from, 0.10, 0), point(end, 0.30, 0)}, + Start: &CostBasis{At: from, ChargesKnown: true, Charges: []Charge{ + charge(t, from, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + }}, + End: &CostBasis{At: end, ChargesKnown: true, Charges: []Charge{ + charge(t, end, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + charge(t, end, ChargeKindFargate, "1vCPU/2GB", 4, 0.05), + }}, + } + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + if got := chargeSub(t, a, TermChargeRate).Micro; got != 0 { + t.Errorf("charge-rate = %d µUSD/h, want 0: no rate moved, a new line merely appeared", got) + } +} + +// TestZeroUnitChargeLineHasNoObservedRate mirrors the node side: a line with +// no units states a rate nobody was paying, and must not create a rate term. +func TestZeroUnitChargeLineHasNoObservedRate(t *testing.T) { + from, to := t0, t0.Add(hours(24)) + end := to.Add(-time.Minute) + in := Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{point(from, 0.10, 0), point(end, 0.30, 0)}, + Start: &CostBasis{At: from, ChargesKnown: true, Charges: []Charge{ + charge(t, from, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + charge(t, from, ChargeKindFargate, "1vCPU/2GB", 0, 0.04), + }}, + End: &CostBasis{At: end, ChargesKnown: true, Charges: []Charge{ + charge(t, end, ChargeKindFargate, "0.25vCPU/0.5GB", 8, 0.0125), + charge(t, end, ChargeKindFargate, "1vCPU/2GB", 4, 0.05), + }}, + } + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + if got := chargeSub(t, a, TermChargeRate).Micro; got != 0 { + t.Errorf("charge-rate = %d µUSD/h, want 0: the 1vCPU/2GB line was never run at the start rate", got) + } +} + +// TestDuplicateChargeRowsMergeNotDouble: two rows for one line is a collector +// detail, not two terms. +func TestDuplicateChargeRowsMergeNotDouble(t *testing.T) { + split := fargateInput(t) + split.End.Charges = []Charge{ + charge(t, split.End.At, ChargeKindFargate, "0.25vCPU/0.5GB", 4, 0.0125), + charge(t, split.End.At, ChargeKindFargate, "1vCPU/2GB", 5, 0.055), + charge(t, split.End.At, ChargeKindFargate, "1vCPU/2GB", 3, 0.055), + } + merged := mustWhy(t, fargateInput(t)) + got := mustWhy(t, split) + checkChargeSums(t, got) + if got.ChargeToMicro != merged.ChargeToMicro { + t.Fatalf("split rows price at %d µUSD/h, merged at %d", got.ChargeToMicro, merged.ChargeToMicro) + } + for _, kind := range chargeChainOrder { + if a, b := chargeSub(t, got, kind).Micro, chargeSub(t, merged, kind).Micro; a != b { + t.Errorf("sub-term %s is %d for split rows and %d for merged", kind, a, b) + } + } +} + +// TestKnownButEmptyChargesAreAClaim: "we looked, there was nothing" is a +// different and more useful answer than "we did not look". It is stated as a +// zero charges term with no chain, not as silence. +func TestKnownButEmptyChargesAreAClaim(t *testing.T) { + in := fargateInput(t) + in.Start.Charges, in.End.Charges = nil, nil + in.Timeline = []evidence.TimelinePoint{point(in.From, 1.00, 10), point(in.End.At, 1.00, 10)} + + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + checkCitable(t, a) + + parent := mustTerm(t, a, TermCharges) + if parent.Micro != 0 { + t.Errorf("%q = %d µUSD/h, want 0", TermCharges, parent.Micro) + } + if len(parent.Of) != 0 { + t.Errorf("%q carries %d sub-terms; an empty dimension has no chain to show", TermCharges, len(parent.Of)) + } + if !strings.Contains(parent.Label, "no non-node charges") { + t.Errorf("label %q does not state that the dimension was checked and empty", parent.Label) + } + if !a.ChargesKnown { + t.Error("ChargesKnown is false although both edges stated an empty charge set") + } +} + +// TestChargeCannotBeConstructedUncited is the citation contract for the new +// dimension: NewCharge refuses, and a hand-built uncitable charge fails the +// whole answer rather than being silently dropped. Dropping it would +// understate the charge total and hand the difference to a term that did not +// earn it. +func TestChargeCannotBeConstructedUncited(t *testing.T) { + if _, err := NewCharge(ChargeKindFargate, "1vCPU/2GB", 4, 0.05); err == nil { + t.Fatal("NewCharge returned a charge with no citation") + } else if !strings.Contains(err.Error(), "citable") { + t.Errorf("error %q does not name the missing citation", err) + } + if _, err := NewCharge(ChargeKindFargate, "1vCPU/2GB", 4, 0.05, "not-an-id"); err == nil { + t.Fatal("NewCharge accepted an unparseable citation") + } + + in := fargateInput(t) + in.End.Charges = append(in.End.Charges, Charge{Kind: ChargeKindFargate, Class: "2vCPU/4GB", Units: 1, UnitUSDPerHour: 0.1}) + if _, err := WhyCost(in); err == nil { + t.Fatal("WhyCost accepted a charge with no citation") + } + + dangling := fargateInput(t) + dangling.End.Charges[0].Evidence = []ID{"tl/prod%eks@notanumber"} + if _, err := WhyCost(dangling); err == nil { + t.Fatal("WhyCost accepted a charge citing an unparseable id") + } +} + +// TestChargeValidationRefusesRatherThanGuesses. +func TestChargeValidationRefusesRatherThanGuesses(t *testing.T) { + id := anchorID(t0) + cases := []struct { + name string + mut func(*CostBasis) + }{ + {"no kind", func(b *CostBasis) { b.Charges = []Charge{{Units: 1, UnitUSDPerHour: 0.1, Evidence: []ID{id}}} }}, + {"negative units", func(b *CostBasis) { + b.Charges = []Charge{{Kind: "fargate", Units: -1, UnitUSDPerHour: 0.1, Evidence: []ID{id}}} + }}, + {"units over cap", func(b *CostBasis) { + b.Charges = []Charge{{Kind: "fargate", Units: maxChargeUnits + 1, UnitUSDPerHour: 0.1, Evidence: []ID{id}}} + }}, + {"negative rate", func(b *CostBasis) { + b.Charges = []Charge{{Kind: "fargate", Units: 1, UnitUSDPerHour: -0.1, Evidence: []ID{id}}} + }}, + {"NaN rate", func(b *CostBasis) { + b.Charges = []Charge{{Kind: "fargate", Units: 1, UnitUSDPerHour: math.NaN(), Evidence: []ID{id}}} + }}, + {"absurd rate", func(b *CostBasis) { + b.Charges = []Charge{{Kind: "fargate", Units: 1, UnitUSDPerHour: MaxUSD * 10, Evidence: []ID{id}}} + }}, + {"rows without the flag", func(b *CostBasis) { + b.ChargesKnown = false + b.Charges = []Charge{{Kind: "fargate", Units: 1, UnitUSDPerHour: 0.1, Evidence: []ID{id}}} + }}, + {"too many lines", func(b *CostBasis) { + b.Charges = make([]Charge, maxCharges+1) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := fargateInput(t) + in.End.Charges = nil + tc.mut(in.End) + if _, err := WhyCost(in); err == nil { + t.Fatal("WhyCost accepted an input it cannot price honestly") + } + }) + } +} + +// TestChargeTermsCarryTheEvidenceOfTheLinesThatMoved: a term must cite the +// charges it was computed from, not merely something that resolves. +func TestChargeTermsCarryTheEvidenceOfTheLinesThatMoved(t *testing.T) { + in := fargateInput(t) + in.Events = append(in.Events, ev(t0.Add(time.Hour), evidence.EventPricingChange, evidence.SeverityInfo, + evidence.ClusterSubject(testCluster), map[string]string{"kind": "fargate"})) + a := mustWhy(t, in) + + startCite, endCite := string(anchorID(in.From)), string(anchorID(in.End.At)) + for _, tc := range []struct{ kind, want string }{ + {TermCharges, startCite}, + {TermChargeVolume, endCite}, + {TermChargeClassMix, endCite}, + {TermChargeRate, "/pricing-change@"}, + } { + term := mustTerm(t, a, tc.kind) + if !anyIDContains(term.Evidence, tc.want) { + t.Errorf("term %s cites %v, none containing %q", tc.kind, term.Evidence, tc.want) + } + } + // Every emitted id must parse — Verify then requires it to resolve. + for _, term := range append([]Term{mustTerm(t, a, TermCharges)}, mustTerm(t, a, TermCharges).Of...) { + if len(term.Evidence) == 0 { + t.Fatalf("charge term %q carries no evidence id", term.Kind) + } + for _, id := range term.Evidence { + if _, err := Parse(id); err != nil { + t.Errorf("charge term %q emitted an unparseable id %q: %v", term.Kind, id, err) + } + } + } +} + +// TestVerifyCoversChargeCitations is requirement 5: a payload whose charge +// citations no longer resolve fails the publish gate exactly as a node term's +// do. It works without a line of new Verify code because the charges chain is +// a sub-attribution — which is most of the argument for nesting it. +func TestVerifyCoversChargeCitations(t *testing.T) { + in := fargateInput(t) + mem := memWithFixtures(t, in) + a := mustWhy(t, in) + r := Resolver{Store: mem} + if err := a.Verify(r); err != nil { + t.Fatalf("baseline must verify: %v", err) + } + // Every charge citation is in the set a narrating session may quote. + cited := map[ID]bool{} + for _, id := range a.Citations() { + cited[id] = true + } + for _, s := range mustTerm(t, a, TermCharges).Of { + for _, id := range s.Evidence { + if !cited[id] { + t.Errorf("charge sub-term %q cites %q, which Citations() omits", s.Kind, id) + } + } + } + + for _, tc := range []struct { + name string + mut func(*Attribution) + }{ + {"on the charges term", func(a *Attribution) { + for i := range a.Terms { + if a.Terms[i].Kind == TermCharges { + a.Terms[i].Evidence = append(a.Terms[i].Evidence, "tl/prod-eks-1@1") + } + } + }}, + {"on a charge sub-term", func(a *Attribution) { + for i := range a.Terms { + if a.Terms[i].Kind == TermCharges { + a.Terms[i].Of[0].Evidence = append(a.Terms[i].Of[0].Evidence, "tl/prod-eks-1@1") + } + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + broken := mustWhy(t, fargateInput(t)) + tc.mut(broken) + if err := broken.Verify(r); err == nil { + t.Fatal("a dangling charge citation passed Verify") + } + }) + } +} + +// TestCheckCatchesEveryWayTheChargeIdentityCanBreak exercises the new guard +// directly, the way TestCheckCatchesEveryWayTheInvariantCanBreak does the old +// one. It is the net under a future refactor. +func TestCheckCatchesEveryWayTheChargeIdentityCanBreak(t *testing.T) { + anchor := []ID{"tl/c@1"} + ok := func() *Attribution { + charges := newTerm(TermCharges, "l", 100, nil, anchor) + charges.Of = []Term{ + newTerm(TermChargeVolume, "l", 60, nil, anchor), + newTerm(TermChargeUnattributed, "l", 40, nil, anchor), + } + return &Attribution{ + DeltaMicro: 100, + ChargesKnown: true, + ChargeFromMicro: 10, ChargeToMicro: 110, ChargeDeltaMicro: 100, + Terms: []Term{charges}, + Residual: newTerm(TermResidual, "l", 0, nil, anchor), + } + } + if err := ok().check(); err != nil { + t.Fatalf("a well-formed charges attribution must pass: %v", err) + } + cases := []struct { + name string + mut func(*Attribution) + want string + }{ + {"charges term disagrees with the supplied dimension", func(a *Attribution) { + a.Terms[0].Micro, a.Terms[0].Of[1].Micro = 99, 39 + a.DeltaMicro = 99 + }, "supplied charges differ by"}, + {"endpoints disagree with the stated delta", func(a *Attribution) { a.ChargeToMicro = 111 }, "ChargeDelta is"}, + {"charge sub-terms do not sum", func(a *Attribution) { a.Terms[0].Of[0].Micro = 61 }, "sub-terms of"}, + {"charge sub-term with no citation", func(a *Attribution) { a.Terms[0].Of[0].Evidence = nil }, "no evidence id"}, + {"a charges term without a two-edge dimension", func(a *Attribution) { + a.ChargesKnown, a.ChargeFromMicro, a.ChargeToMicro, a.ChargeDeltaMicro = false, 0, 0, 0 + }, "without a charges dimension"}, + {"charge amounts without a charges term", func(a *Attribution) { + a.Terms, a.ChargesKnown = nil, false + a.Residual = newTerm(TermResidual, "l", 100, nil, anchor) + }, "charge amounts reported"}, + {"a known dimension with no charges term", func(a *Attribution) { + a.Terms = []Term{newTerm(TermNodeCount, "l", 100, nil, anchor)} + }, "no \"charges\" term was emitted"}, + {"two charges terms", func(a *Attribution) { + a.Terms = append(a.Terms, newTerm(TermCharges, "l", 0, nil, anchor)) + }, "two \"charges\" terms"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := ok() + tc.mut(a) + err := a.check() + if err == nil { + t.Fatalf("check passed; expected an error containing %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q, want it to contain %q", err, tc.want) + } + }) + } +} + +// TestChargeShuffleIsIdentical is the determinism test the float-associativity +// trap demands, applied to the new dimension: nothing about the answer may +// depend on the order the caller assembled its charge rows in. +func TestChargeShuffleIsIdentical(t *testing.T) { + base := mustWhy(t, fargateInput(t)) + want, err := json.Marshal(base) + if err != nil { + t.Fatalf("marshal: %v", err) + } + rng := rand.New(rand.NewSource(20260826)) + for i := 0; i < 64; i++ { + in := fargateInput(t) + // Split every line into single-unit rows first, so the shuffle has + // something to permute and the merge rule is exercised at the same + // time. + for _, b := range []*CostBasis{in.Start, in.End} { + var rows []Charge + for _, c := range b.Charges { + for u := int64(0); u < c.Units; u++ { + one := c + one.Units = 1 + rows = append(rows, one) + } + } + rng.Shuffle(len(rows), func(i, j int) { rows[i], rows[j] = rows[j], rows[i] }) + b.Charges = rows + } + got, err := json.Marshal(mustWhy(t, in)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != string(want) { + t.Fatalf("shuffle %d changed the answer\n got: %s\nwant: %s", i, got, want) + } + } +} + +// TestChargeProseStatesItsConvention: an answer that states one attribution +// convention and hides the other is half an audit record. +func TestChargeProseStatesItsConvention(t *testing.T) { + a := mustWhy(t, fargateInput(t)) + p := a.Prose() + for _, want := range []string{ + "charge-volume → charge-kind-mix → charge-class-mix → charge-rate", + "node-count → spot-ratio → instance-mix → pricing-catalog → charges", + signedUSD(mustTerm(t, a, TermCharges).Micro), + signedUSD(chargeSub(t, a, TermChargeClassMix).Micro), + } { + if !strings.Contains(p, want) { + t.Errorf("prose is missing %q\n%s", want, p) + } + } + // Prose over an attribution with no charges must be unchanged. + if strings.Contains(mustWhy(t, baseInput()).Prose(), "Charge attribution order") { + t.Error("prose announces a charge order for an attribution that has none") + } +} + +// TestChargeQuantizationBoundMatchesTheNodeSide keeps the two dimensions' +// honesty thresholds from drifting apart. +func TestChargeQuantizationBoundMatchesTheNodeSide(t *testing.T) { + if maxChargeQuantizationMicro != maxQuantizationMicro { + t.Fatalf("charge quantization bound is %d µUSD/h but the node side allows %d", + maxChargeQuantizationMicro, maxQuantizationMicro) + } +} + +// TestChargesWithoutAComposition: the two dimensions are independent. A +// caller that knows its Fargate bill but not its fleet still gets a charge +// decomposition, and the fleet stays in the residual with the existing note. +func TestChargesWithoutAComposition(t *testing.T) { + from, to := t0, t0.Add(hours(24)) + end := to.Add(-time.Minute) + in := Input{ + Cluster: testCluster, From: from, To: to, + Timeline: []evidence.TimelinePoint{point(from, 1.20, 10), point(end, 1.30, 10)}, + Start: &CostBasis{At: from, ChargesKnown: true, Charges: []Charge{ + charge(t, from, ChargeKindFargate, "1vCPU/2GB", 4, 0.05), + }}, + End: &CostBasis{At: end, ChargesKnown: true, Charges: []Charge{ + charge(t, end, ChargeKindFargate, "1vCPU/2GB", 6, 0.05), + }}, + } + a := mustWhy(t, in) + checkSums(t, a) + checkChargeSums(t, a) + checkCitable(t, a) + + if got := mustTerm(t, a, TermCharges); got.Micro != 100_000 { + t.Errorf("%q = %d µUSD/h, want 100000", TermCharges, got.Micro) + } + if got := mustTerm(t, a, TermNodeCount).Micro; got != 0 { + t.Errorf("node-count = %d µUSD/h, want 0: no node group was supplied", got) + } + // The unattributed $1.20/h of fleet is a *level*, not a move: it is + // identical at both edges, so it cancels out of ΔCost and the residual is + // zero even though most of the bill is unmodelled. The note is what keeps + // that from reading as "we priced everything". + if a.Residual.Micro != 0 { + t.Errorf("residual = %d µUSD/h, want 0: the unpriced fleet is the same at both edges, so it moved nothing", a.Residual.Micro) + } + if !strings.Contains(strings.Join(a.Notes, " "), "unpriced") { + t.Errorf("expected a note naming the unpriced fleet, got %v", a.Notes) + } +} diff --git a/pkg/explain/prose.go b/pkg/explain/prose.go index 53909c4..203d060 100644 --- a/pkg/explain/prose.go +++ b/pkg/explain/prose.go @@ -196,6 +196,12 @@ func (a *Attribution) Prose() string { signedUSD(a.DeltaMicro), signedMonthly(a.DeltaMicro), formatUSD(a.FromUSDPerHour), formatUSD(a.ToUSDPerHour)) fmt.Fprintf(&b, "Attribution order: %s (see package doc).\n", strings.Join(a.Order, " → ")) + // The charges dimension has its own order and its own argument for it; an + // answer that states one convention and hides the other is half an audit + // record. Printed only when charges were actually decomposed. + if len(a.ChargeOrder) > 0 { + fmt.Fprintf(&b, "Charge attribution order: %s (see charges.go).\n", strings.Join(a.ChargeOrder, " → ")) + } ordered := append([]Term(nil), a.Terms...) sort.SliceStable(ordered, func(i, j int) bool { diff --git a/pkg/explain/testdata/fuzz/FuzzChargeInvariants/ac25dfe8a8b5152b b/pkg/explain/testdata/fuzz/FuzzChargeInvariants/ac25dfe8a8b5152b new file mode 100644 index 0000000..73a1213 --- /dev/null +++ b/pkg/explain/testdata/fuzz/FuzzChargeInvariants/ac25dfe8a8b5152b @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0102020100000") diff --git a/pkg/explain/whycost.go b/pkg/explain/whycost.go index 9aa94be..d699de7 100644 --- a/pkg/explain/whycost.go +++ b/pkg/explain/whycost.go @@ -110,6 +110,21 @@ type CostBasis struct { At time.Time `json:"at"` Groups []NodeGroup `json:"groups,omitempty"` Namespaces []NamespaceDemand `json:"namespaces,omitempty"` + + // Charges is the second dimension: cost billed per line item rather than + // per node — Fargate pods above all, and anything else with the same + // `Σ units × unit rate` shape. See charges.go for the decomposition and + // its order. Groups deliberately excludes Fargate "nodes", so without + // this dimension their whole cost lands in the residual. + Charges []Charge `json:"charges,omitempty"` + // ChargesKnown states that Charges is the complete non-node charge set at + // this edge. It is a flag rather than a nil check because an empty slice + // has to be able to mean "we looked, there were none" — the same + // empty-is-not-missing distinction the nil-ness of *CostBasis draws for + // the fleet. Charges are attributed only when BOTH edges set it: a + // missing edge is not a zero, and treating it as one would report a + // cluster's entire Fargate bill as having appeared or vanished. + ChargesKnown bool `json:"chargesKnown,omitempty"` } func (b *CostBasis) supplied() bool { return b != nil } @@ -185,12 +200,24 @@ type Attribution struct { FromNodes int64 `json:"fromNodes"` ToNodes int64 `json:"toNodes"` + // ChargesKnown reports that both window edges stated their non-node + // charges, so the `charges` term below is a decomposition rather than a + // gap in the residual. When it is false the three Charge* amounts are + // meaningless and are omitted. + ChargesKnown bool `json:"chargesKnown,omitempty"` + ChargeFromMicro Micro `json:"chargeFromMicroUSDPerHour,omitempty"` + ChargeToMicro Micro `json:"chargeToMicroUSDPerHour,omitempty"` + ChargeDeltaMicro Micro `json:"chargeDeltaMicroUSDPerHour,omitempty"` + Terms []Term `json:"terms"` Residual Term `json:"residual"` // Order is the attribution order actually used, echoed into the payload // so a stored answer states the convention it was computed under. Order []string `json:"order"` + // ChargeOrder is the order inside the charges dimension, echoed for the + // same reason. Empty when no charges were supplied at both edges. + ChargeOrder []string `json:"chargeOrder,omitempty"` // Notes record every degradation, guard trip and disagreement between // the observed timeline and the supplied composition. An empty Notes is // a claim that nothing was approximated. @@ -288,7 +315,7 @@ func (b *CostBasis) validate() error { return fmt.Errorf("explain: namespace %q has negative demand", ns.Namespace) } } - return nil + return b.validateCharges() } // gkey identifies a node group. Comparable, so it is a safe map key; the @@ -467,6 +494,14 @@ func WhyCost(in Input) (*Attribution, error) { events := gatherEvents(pts, in.Events, in.From, in.To) actions := actionsInWindow(in.Actions, in.Cluster, in.From, in.To) + // The charges dimension is folded in first only so the composition notes + // below can compare the *whole* modelled cost — nodes plus charges — + // against the observed one. Its term is appended last, in chain order. + chA, chB, chargesKnown, err := chargeEdges(in, att) + if err != nil { + return nil, err + } + var terms []Term var nodeTerm *Term // pbarA is the reference unit price (µUSD per node-hour) the node-count @@ -492,15 +527,27 @@ func WhyCost(in Input) (*Attribution, error) { att.Notes = append(att.Notes, fmt.Sprintf( "end composition holds %d nodes but the observed timeline point holds %d; the difference lands in the residual", b.nodes, end.Nodes)) } - if a.total != cFrom { + // The modelled cost is the fleet plus whatever charges were supplied. + // Comparing the fleet alone against an observed cost that includes + // Fargate would report an unpriced gap that the charges dimension has + // in fact just priced. + modelA, err := add(a.total, chA.sum()) + if err != nil { + return nil, err + } + modelB, err := add(b.total, chB.sum()) + if err != nil { + return nil, err + } + if modelA != cFrom { att.Notes = append(att.Notes, fmt.Sprintf( - "start composition prices at %.6f USD/h but the observed cost is %.6f USD/h; the unpriced difference lands in the residual", - a.total.USD(), cFrom.USD())) + "%s prices at %.6f USD/h but the observed cost is %.6f USD/h; the unpriced difference lands in the residual", + modelSubject("start", chargesKnown), modelA.USD(), cFrom.USD())) } - if b.total != cTo { + if modelB != cTo { att.Notes = append(att.Notes, fmt.Sprintf( - "end composition prices at %.6f USD/h but the observed cost is %.6f USD/h; the unpriced difference lands in the residual", - b.total.USD(), cTo.USD())) + "%s prices at %.6f USD/h but the observed cost is %.6f USD/h; the unpriced difference lands in the residual", + modelSubject("end", chargesKnown), modelB.USD(), cTo.USD())) } composed, ref, err := decomposeComposition(a, b, anchors, events, att) if err != nil { @@ -540,6 +587,28 @@ func WhyCost(in Input) (*Attribution, error) { } } + // The charges dimension is the last link of the top-level chain, and it + // is appended after attributeNodeCount so no pointer into `terms` is + // invalidated. Its own chain lives in Term.Of; see charges.go. + if chargesKnown { + chargeTerm, err := decomposeCharges(chA, chB, anchors, events, att) + if err != nil { + return nil, err + } + // Recomputed here rather than read off the term: check() compares the + // two, so a future change to how the parent is built has something to + // disagree with. + chargeDelta, err := sub(chB.total, chA.total) + if err != nil { + return nil, err + } + terms = append(terms, chargeTerm) + att.Order = append(att.Order, TermCharges) + att.ChargeOrder = append([]string(nil), chargeChainOrder...) + att.ChargesKnown = true + att.ChargeFromMicro, att.ChargeToMicro, att.ChargeDeltaMicro = chA.total, chB.total, chargeDelta + } + // Terms are emitted in chain order, and only when they carry a number or // a citation worth showing. att.Terms = terms @@ -604,6 +673,55 @@ func (a *Attribution) check() error { return fmt.Errorf("explain: BUG: sub-terms of %q sum to %d µUSD/h, parent is %d µUSD/h", t.Kind, sum, t.Micro) } } + return a.checkCharges() +} + +// checkCharges enforces the charges dimension's own arithmetic identity, in +// the same place and the same way as the central one: +// +// charges.Micro == ChargeDeltaMicro (the exact repriced difference) +// +// The companion identity, sum(charges.Of) == charges.Micro, is enforced by +// the sub-term loop above — the charges chain is deliberately a +// sub-attribution so that it inherits it rather than reimplementing it. +// +// The first identity is what stops the second from being vacuous. Without it +// the parent could be any number at all and charge-unattributed would dutifully +// absorb the difference, which is precisely the failure this package exists to +// refuse: error hidden inside a confidently-labelled term instead of reported. +func (a *Attribution) checkCharges() error { + var charges *Term + for i := range a.Terms { + if a.Terms[i].Kind == TermCharges { + if charges != nil { + return fmt.Errorf("explain: BUG: two %q terms in one attribution", TermCharges) + } + charges = &a.Terms[i] + } + } + if !a.ChargesKnown { + if charges != nil { + return fmt.Errorf("explain: BUG: a %q term was emitted without a charges dimension at both window edges", TermCharges) + } + if a.ChargeDeltaMicro != 0 || a.ChargeFromMicro != 0 || a.ChargeToMicro != 0 { + return fmt.Errorf("explain: BUG: charge amounts reported without a charges dimension at both window edges") + } + return nil + } + if charges == nil { + return fmt.Errorf("explain: BUG: charges are known at both edges but no %q term was emitted", TermCharges) + } + stated, err := sub(a.ChargeToMicro, a.ChargeFromMicro) + if err != nil { + return err + } + if stated != a.ChargeDeltaMicro { + return fmt.Errorf("explain: BUG: charge endpoints differ by %d µUSD/h but ChargeDelta is %d µUSD/h", stated, a.ChargeDeltaMicro) + } + if charges.Micro != a.ChargeDeltaMicro { + return fmt.Errorf("explain: BUG: the %q term is %d µUSD/h but the supplied charges differ by %d µUSD/h", + TermCharges, charges.Micro, a.ChargeDeltaMicro) + } return nil }