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 }