lots.gno
32.29 Kb · 816 lines
1// Package lots is the cost of goods consumed as a pure function of how much
2// of a lot has been consumed.
3//
4// A realm stores receipts of a stock-keeping unit — each a quantity and what
5// was paid for it — and asks this package what the goods it just shipped cost.
6// Nothing here reads the chain, holds coins, or knows what a ledger is, so a
7// realm can test a year of its own inventory costing without a node.
8//
9// p, _ := NewPool(FIFO, 64)
10// p.Receive(100, 2500) // 100 units for 2500 in total
11// p.Receive(50, 1400) // a second, dearer receipt
12// cogs, _ := p.Consume(120) // what the shipment cost: DERIVED
13// onHand, _ := p.CostOnHand() // what is left is worth
14//
15// # The amount is never a parameter
16//
17// This is the thesis the rest of the package serves. A caller says how *many*
18// units left the shelf; it never says what they cost. The cost is computed from
19// the lots the units came out of, so a realm cannot post a cost of goods sold
20// that disagrees with the inventory it holds — not because it is careful, but
21// because there is no argument through which the two could be made to disagree.
22//
23// [p/.../depreciation] makes the same move for a capital asset, where the
24// charge is a function of periods elapsed. Here it is sharpened: a depreciation
25// schedule is fixed at acquisition and a caller supplies only time, while an
26// inventory pool is mutated by every receipt, and the function still holds.
27//
28// # Why the cost is a difference of a pure function, and not a running average
29//
30// The obvious implementation keeps a remaining quantity and a remaining cost
31// and, for a shipment of n units, charges
32//
33// remainingCost * n / remainingQuantity
34//
35// It is wrong in a way that is specific and quiet. The division truncates, and
36// what it truncates is retained in the lot rather than charged, so the cost
37// charged for the first k units out of a lot **depends on how those k units
38// were sliced into shipments**. Measured, with the whole lot's life evaluated:
39//
40// lot(Qty=7, Cost=10) consumed as [1,1,1]
41// naive -> [1, 1, 1] total 3
42// telescoping -> [1, 1, 2] total 4
43//
44// Nothing here is an accumulator. [Lot.CostAt] is a pure function of the
45// cumulative units consumed,
46//
47// CostAt(k) == Cost*k / Qty
48//
49// and [Lot.Take] is its difference at two points. So charges **telescope** —
50//
51// Take(a→b) + Take(b→c) == Take(a→c)
52//
53// — by construction rather than by careful remainder arithmetic, and there is no
54// stored remaining cost that could drift from the lot at all. A realm that
55// loses track of what it has charged can recompute it from the quantities.
56//
57// # What the naive form actually gets wrong, stated precisely
58//
59// It **mis-times** cost; it does not lose it. Over a lot's whole life the naive
60// form charges exactly Cost, because the shipment that exhausts the lot has
61// `n == remainingQuantity` and so charges the whole remainder. Saying "it loses
62// money" would be a stronger and false claim, and a reader who checks the
63// totals would correctly conclude the warning was overstated and ignore the
64// real defect. The real defect is that a *period's* cost of goods sold, and the
65// inventory value carried at any moment before exhaustion, both depend on
66// shipment slicing — which is a misstatement of both the income statement and
67// the balance sheet, for a quantity nobody chose.
68//
69// Measured by differential sweep against exact arithmetic, over quantities 1
70// to 12 and costs 1 to 50, evaluating **every ordered composition** of the
71// quantity (so every way of slicing a lot's whole life into shipments):
72//
73// compositions evaluated 204750
74// naive path-dependent 108320 over 260 of 600 (Qty,Cost) pairs
75// telescoping path-dependent 0
76// naive conservation failures 0
77// telescoping conservation failures 0
78// worst naive mis-timing 3 units of cost, at Qty=12 Cost=6
79//
80// "Path-dependent" has a definition, because the count is worthless without
81// one: a composition is path-dependent if the cumulative cost charged after
82// some prefix of it differs from the cumulative cost the same number of units
83// would have carried had they shipped one at a time. The two zeroes on the
84// conservation lines are the evidence for the paragraph above — the naive form
85// reaches the right total every time and gets there wrongly — and the worst
86// case is half of a small lot's cost, not a rounding crumb.
87//
88// # The weighted-average method re-anchors at every receipt, and must
89//
90// Under [WAVG] a receipt merges into the single live lot: the merged lot's
91// quantity is what remained plus what arrived, its cost is what remained *was
92// worth* plus what arrived cost, and its consumed count resets to zero. The
93// reset is not bookkeeping convenience and is not a defect — it is forced.
94// CostAt is a function of the lot's own Qty and Cost, and a receipt changes
95// both, so a consumed count measured against the old pair means nothing
96// against the new one. Telescoping therefore holds **within an anchor epoch**,
97// and across the re-anchor what holds instead is exact cost neutrality: the
98// merged lot's cost is the outgoing lot's remaining cost plus the receipt's,
99// with nothing dropped and nothing invented. [Pool.CrossFootCost] is what
100// proves it, and it is asserted over receipt/consume interleavings in the
101// suite.
102//
103// The asymmetry this creates is worth knowing before choosing a method: WAVG
104// can **refuse** a receipt that FIFO and LIFO accept, because the merged lot
105// must satisfy the overflow precondition `Cost <= maxInt64/Qty` that two
106// separate lots would each satisfy on their own. See [Pool.Receive].
107//
108// # An error return does not revert a Gno transaction
109//
110// Only a panic reverts. So every mutating method here stages its whole effect
111// — a copy of the lots it will touch, and the total it will charge — and
112// commits only once the last step has succeeded. A method that returned an
113// error after mutating half the pool would leave a realm holding inventory
114// whose cost had been partly charged, with the realm's own error handling
115// unable to undo it, and the realm would have no way to detect it short of a
116// cross-foot. This package does not panic on caller error; it returns errors
117// and keeps the pool untouched when it does.
118//
119// # What this package does not claim
120//
121// - **No novelty for FIFO, LIFO or weighted average.** They are textbook and
122// older than software. What was not found in the searched sources is an
123// on-chain integer implementation with a path-independent cost function;
124// see DISCOVERY.md.
125// - **Nothing about money.** A lot cost is a number in a record. This package
126// never sees a coin, never holds value, and a realm using it must not infer
127// custody from it.
128// - **No specific identification.** The four methods a textbook lists are
129// three here. Specific identification — the shipper naming which lot a unit
130// came from — would make the consumed amount depend on a caller-supplied
131// choice, which is the one thing the thesis above rules out. A realm that
132// needs it should hold one pool per identified lot.
133// - **No units of measure, no currency, no SKU identity.** A quantity is an
134// integer and a cost is an integer. Deciding that they are kilograms and
135// thousandths of a ugnot, and that this pool is the one for SKU "A-1", is
136// the realm's job.
137// - **No negative inventory, no write-offs, no revaluation.** Consuming more
138// than is on hand is [ErrInsufficient], never a negative lot. Shrinkage and
139// lower-of-cost-or-market are not here; a realm that needs them needs a
140// fourth and fifth account and should say so in its own chart.
141//
142// [p/.../depreciation]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation
143package lots
144
145import "errors"
146
147// MaxLotsLimit bounds the live lots a single pool may hold, and so bounds
148// every walk in this package: [Pool.CostOnHand], [Pool.QtyOnHand],
149// [Pool.Consume] and both cross-foots are O(live lots).
150//
151// What the bound costs is stated rather than waved at. A pool at the limit
152// makes Consume scan up to 1024 lots and the cross-foots walk them all, and a
153// realm that renders many pools in one query multiplies that. The limit is the
154// ceiling a caller may ask for, not a recommendation: a caller passes its own
155// smaller cap to [NewPool] and should, because exhausted lots are dropped but
156// live ones are not, and a pool fed many small receipts and consumed in large
157// shipments is the shape that fills up.
158const MaxLotsLimit = 1024
159
160var (
161 // ErrBadQty is returned when a quantity is not positive. A zero-quantity
162 // receipt is not a lot and a zero-quantity shipment is not an event;
163 // accepting either would have a realm post an entry for nothing.
164 ErrBadQty = errors.New("lots: quantity must be positive")
165
166 // ErrBadCost is returned when a cost is negative. Zero is allowed: goods
167 // received at no cost are ordinary — donated stock, samples, a
168 // promotional allowance that zeroed the invoice — and they consume
169 // exactly zero cost for their whole life, which is the right answer and
170 // not a degenerate one.
171 ErrBadCost = errors.New("lots: cost must not be negative")
172
173 // ErrBadConsumed is returned when a lot's consumed count is negative or
174 // exceeds its quantity. It is reachable only on a lot a caller built by
175 // hand; lots this package produces always satisfy it.
176 ErrBadConsumed = errors.New("lots: consumed must be in [0, qty]")
177
178 // ErrBadSeq is returned when a lot's receipt sequence is negative.
179 ErrBadSeq = errors.New("lots: seq must not be negative")
180
181 // ErrBadMethod is returned for a method outside the declared set.
182 ErrBadMethod = errors.New("lots: unknown method")
183
184 // ErrBadMaxLots is returned when a pool's lot cap is not positive or
185 // exceeds MaxLotsLimit.
186 ErrBadMaxLots = errors.New("lots: maxLots must be in [1, MaxLotsLimit]")
187
188 // ErrPoolFull is returned when a receipt would exceed the pool's own lot
189 // cap. It is fail-closed: the receipt is refused and the pool is
190 // untouched, so a realm sees a refused receipt rather than silently
191 // merged or dropped stock.
192 ErrPoolFull = errors.New("lots: lot cap reached")
193
194 // ErrInsufficient is returned when a shipment exceeds the quantity on
195 // hand. There is no partial fill and no negative inventory: a realm that
196 // wants to ship what it can must ask QtyOnHand first and decide.
197 ErrInsufficient = errors.New("lots: quantity exceeds quantity on hand")
198
199 // ErrSeqExhausted is returned when the receipt sequence space is spent.
200 // Unreachable in practice and checked anyway, because the alternative is
201 // two lots sharing a sequence and a silently non-deterministic order.
202 ErrSeqExhausted = errors.New("lots: receipt sequence space exhausted")
203
204 // ErrOverflow is returned when the arithmetic would wrap. Most overflow
205 // is excluded by a precondition established when a lot is constructed;
206 // see Lot.check and Pool.Receive.
207 ErrOverflow = errors.New("lots: int64 overflow")
208)
209
210// The bound is written as a literal rather than as the usual
211// `int64(1)<<63 - 1` idiom, which does not compile: the shift is evaluated in
212// the already-typed int64 and overflows before the subtraction can bring it
213// back in range. It is a compile error here rather than a wrong number, but it
214// is the same shape as the arithmetic this package exists to get right, so it
215// is worth naming.
216const maxInt64 int64 = 9223372036854775807
217
218// Method is the order in which lots are consumed.
219type Method uint8
220
221const (
222 // FIFO consumes the earliest-received live lot first.
223 FIFO Method = 1
224
225 // LIFO consumes the latest-received live lot first.
226 LIFO Method = 2
227
228 // WAVG keeps one lot, merging every receipt into it at a weighted
229 // average. See the package doc on why the merge re-anchors.
230 WAVG Method = 3
231)
232
233// Valid reports whether m is a method this package implements.
234func (m Method) Valid() bool { return m == FIFO || m == LIFO || m == WAVG }
235
236// String names the method. The zero Method is "invalid" rather than empty, so
237// a caller that renders it without checking Valid prints something that reads
238// as wrong instead of something that reads as missing.
239func (m Method) String() string {
240 switch m {
241 case FIFO:
242 return "fifo"
243 case LIFO:
244 return "lifo"
245 case WAVG:
246 return "weighted-average"
247 }
248 return "invalid"
249}
250
251// Lot is one receipt of stock: a quantity, what was paid for all of it, and how
252// much of it has since been consumed. It is a value, is safe to copy, and every
253// method validates its receiver, so a hand-built or zero Lot fails rather than
254// returning a plausible number.
255//
256// Construct with [NewLot] so the overflow precondition is checked once rather
257// than on every call. The fields are exported because a realm must be able to
258// render a lot and persist it, and because [Pool.Lots] hands out copies.
259type Lot struct {
260 // Qty is the quantity received. It never changes: consumption moves
261 // Consumed, not Qty, because CostAt is a function of the original pair
262 // and reducing Qty would redefine the function under its own callers.
263 Qty int64
264
265 // Cost is what the whole of Qty cost. It never changes either, for the
266 // same reason — except under [WAVG], where a receipt replaces the lot
267 // with a freshly anchored one rather than editing this field.
268 Cost int64
269
270 // Consumed is the cumulative quantity consumed from this lot, in
271 // [0, Qty]. It is the sole argument of the cost function.
272 Consumed int64
273
274 // Seq is the receipt order within a pool, assigned by [Pool.Receive].
275 // It orders lots for FIFO and LIFO and **never enters a cost
276 // computation**: two lots with the same Qty and Cost cost the same to
277 // consume whatever their sequence, which is why the ordering decision
278 // and the costing arithmetic can be reviewed separately.
279 Seq int64
280}
281
282// NewLot validates and returns a lot of qty units costing cost in total. Seq
283// is zero; [Pool.Receive] assigns it.
284func NewLot(qty, cost int64) (Lot, error) {
285 l := Lot{Qty: qty, Cost: cost}
286 if err := l.check(); err != nil {
287 return Lot{}, err
288 }
289 return l, nil
290}
291
292// check validates every field and, critically, the overflow precondition that
293// lets [Lot.CostAt] be a total function on a constructed lot. A zero Lot
294// fails, which is why every method calls check rather than trusting its
295// receiver.
296func (l Lot) check() error {
297 if l.Qty <= 0 {
298 return ErrBadQty
299 }
300 if l.Cost < 0 {
301 return ErrBadCost
302 }
303 if l.Consumed < 0 || l.Consumed > l.Qty {
304 return ErrBadConsumed
305 }
306 if l.Seq < 0 {
307 return ErrBadSeq
308 }
309 // Cost*k is the only product in this package's cost function and
310 // k <= Qty, so Cost*Qty bounds it. Qty >= 1 is already established, so
311 // the division is safe.
312 if l.Cost > maxInt64/l.Qty {
313 return ErrOverflow
314 }
315 return nil
316}
317
318// Valid reports whether the lot is well-formed. A zero Lot is not.
319func (l Lot) Valid() bool { return l.check() == nil }
320
321// Remaining is Qty - Consumed: the quantity still on the shelf from this lot.
322//
323// It does not validate, and it is the only method here that does not. On an
324// ill-formed lot it returns an arbitrary number — Lot{Qty: 1, Consumed:
325// minInt64} wraps. A zero result means "exhausted, or invalid", never
326// "exhausted" alone; use [Lot.Exhausted], which does validate. Guard a
327// caller-supplied lot with [Lot.Valid] before quoting Remaining from it.
328func (l Lot) Remaining() int64 { return l.Qty - l.Consumed }
329
330// Exhausted reports whether a well-formed lot has nothing left. An invalid lot
331// is not exhausted — it is invalid, and conflating the two is how a bad lot
332// gets silently dropped by [Pool.Consume]'s housekeeping.
333func (l Lot) Exhausted() bool { return l.Valid() && l.Remaining() == 0 }
334
335// CostAt returns the cumulative cost consumed from this lot once k units in
336// total have been consumed from it:
337//
338// CostAt(k) == Cost*k / Qty
339//
340// It is the one function in this package, and everything else is defined from
341// it. It is monotone non-decreasing in k, zero at k == 0, and **exactly Cost at
342// k == Qty** — that endpoint is not an approximation, because Cost*Qty/Qty is
343// exact in integers, and it is what makes a lot's whole life charge exactly
344// what the lot cost and makes dropping an exhausted lot cost-neutral.
345//
346// k outside [0, Qty] is [ErrBadConsumed] rather than a clamp: a clamp would let
347// a caller's off-by-one read as a valid cost at the boundary.
348func (l Lot) CostAt(k int64) (int64, error) {
349 if err := l.check(); err != nil {
350 return 0, err
351 }
352 if k < 0 || k > l.Qty {
353 return 0, ErrBadConsumed
354 }
355 // Cannot overflow: check established Cost <= maxInt64/Qty and k <= Qty.
356 return (l.Cost * k) / l.Qty, nil
357}
358
359// ConsumedCost returns the cost consumed from this lot so far: CostAt(Consumed).
360func (l Lot) ConsumedCost() (int64, error) { return l.CostAt(l.Consumed) }
361
362// RemainingCost returns what the units still on the shelf are carried at:
363// Cost - ConsumedCost. It is defined in terms of CostAt so the two can never
364// disagree, which is the same reason [Lot.Take] is. It is zero exactly when the
365// lot is exhausted.
366func (l Lot) RemainingCost() (int64, error) {
367 c, err := l.ConsumedCost()
368 if err != nil {
369 return 0, err
370 }
371 // Non-negative and cannot overflow: CostAt is in [0, Cost].
372 return l.Cost - c, nil
373}
374
375// Take consumes n units from the lot, returning the advanced lot and the cost
376// of those units. The receiver is a value, so a failed Take cannot have
377// mutated anything the caller holds — the atomicity the package doc describes
378// is structural at this level and has to be built by hand one level up, in
379// [Pool.Consume].
380//
381// The charge is the difference of [Lot.CostAt] at the two consumed counts,
382// which makes charges telescope exactly:
383//
384// Take(a→b) + Take(b→c) == Take(a→c) for a <= b <= c
385//
386// So how a realm slices its shipments cannot change what a lot charges in
387// total, nor what it has charged by any point in its life. The charge is never
388// negative; n must be positive and within [Lot.Remaining].
389func (l Lot) Take(n int64) (Lot, int64, error) {
390 if err := l.check(); err != nil {
391 return Lot{}, 0, err
392 }
393 if n <= 0 {
394 return Lot{}, 0, ErrBadQty
395 }
396 if n > l.Remaining() {
397 return Lot{}, 0, ErrInsufficient
398 }
399 before, err := l.CostAt(l.Consumed)
400 if err != nil {
401 return Lot{}, 0, err
402 }
403 after, err := l.CostAt(l.Consumed + n)
404 if err != nil {
405 return Lot{}, 0, err
406 }
407 l.Consumed += n
408 // after >= before because CostAt is monotone non-decreasing, so the
409 // difference is non-negative and cannot overflow: both lie in [0, Cost].
410 return l, after - before, nil
411}
412
413// Pool is the live lots of one stock-keeping unit under one consumption
414// method, plus the four cumulative counters the cross-foots compare against.
415//
416// It is a pointer type with unexported state because the counters and the lots
417// must move together — a realm holding a mutable view of one without the other
418// could leave the pool stating a cost on hand its own receipts contradict.
419// [Pool.Lots] therefore returns copies.
420type Pool struct {
421 method Method
422 maxLots int
423 lots []Lot
424 nextSeq int64
425
426 // The counters. Each is monotone non-decreasing and each is maintained
427 // independently of the lots, which is the whole point: a cross-foot
428 // compares a counter against a walk of the lots, so a bug in either one
429 // is visible rather than cancelling.
430 recvQty int64
431 recvCost int64
432 consQty int64
433 chgCost int64
434}
435
436// NewPool validates and returns an empty pool consuming lots in the given
437// order and holding at most maxLots live lots.
438func NewPool(method Method, maxLots int) (*Pool, error) {
439 if !method.Valid() {
440 return nil, ErrBadMethod
441 }
442 if maxLots <= 0 || maxLots > MaxLotsLimit {
443 return nil, ErrBadMaxLots
444 }
445 return &Pool{method: method, maxLots: maxLots, nextSeq: 1}, nil
446}
447
448// MustNewPool is NewPool, panicking on an invalid configuration. It is for a
449// realm's init, where a configuration error is a deployment bug and should
450// abort the transaction rather than be handled.
451func MustNewPool(method Method, maxLots int) *Pool {
452 p, err := NewPool(method, maxLots)
453 if err != nil {
454 panic(err)
455 }
456 return p
457}
458
459// Method is the consumption order, fixed at construction. It is deliberately
460// immutable: changing it mid-life would recost nothing already charged but
461// would change what the next shipment costs, which is a restatement a
462// sub-ledger must not be able to make silently. A realm that must switch
463// methods opens a new pool and discloses it.
464func (p *Pool) Method() Method { return p.method }
465
466// MaxLots is the pool's own live-lot cap.
467func (p *Pool) MaxLots() int { return p.maxLots }
468
469// LotCount is the number of live lots. Exhausted lots are dropped by
470// [Pool.Consume], so this is not the number of receipts; that is
471// [Pool.ReceiptCount].
472func (p *Pool) LotCount() int { return len(p.lots) }
473
474// ReceiptCount is how many receipts the pool has taken, including those whose
475// lots are exhausted and dropped, and including WAVG merges.
476//
477// The clamp is not defensive noise. nextSeq is 1 on a constructed pool, so a
478// zero-value Pool — one a realm declared rather than built with [NewPool] —
479// would otherwise report **minus one receipt**, an affirmatively wrong number
480// rendered as fact. That is the opposite of the choice [Method.String] makes
481// twenty lines up, where the zero value deliberately reads "invalid" so a
482// caller who skipped the check prints something that reads as wrong rather than
483// as missing. A count has no such word, so the honest answer for a pool that
484// has taken no receipts is zero. Transacting on that pool is still refused:
485// [Pool.Receive] and [Pool.Consume] both return [ErrBadMethod].
486func (p *Pool) ReceiptCount() int64 {
487 if p.nextSeq <= 0 {
488 return 0
489 }
490 return p.nextSeq - 1
491}
492
493// Lots returns the live lots in receipt order, as copies. Mutating the result
494// does not affect the pool.
495func (p *Pool) Lots() []Lot {
496 out := make([]Lot, len(p.lots))
497 copy(out, p.lots)
498 return out
499}
500
501// Lot returns the i'th live lot in receipt order, and whether i was in range.
502func (p *Pool) Lot(i int) (Lot, bool) {
503 if i < 0 || i >= len(p.lots) {
504 return Lot{}, false
505 }
506 return p.lots[i], true
507}
508
509// ReceivedQty is the cumulative quantity received.
510func (p *Pool) ReceivedQty() int64 { return p.recvQty }
511
512// ReceivedCost is the cumulative cost received.
513func (p *Pool) ReceivedCost() int64 { return p.recvCost }
514
515// ConsumedQty is the cumulative quantity consumed.
516func (p *Pool) ConsumedQty() int64 { return p.consQty }
517
518// ChargedCost is the cumulative cost charged out by [Pool.Consume]. This is
519// the figure a realm posts as cost of goods sold, and it is derived: no caller
520// has ever supplied it.
521func (p *Pool) ChargedCost() int64 { return p.chgCost }
522
523// QtyOnHand walks the live lots and sums what remains. It is derived from the
524// lots rather than read from a counter, which is what makes
525// [Pool.CrossFootQty] a comparison of two things rather than a tautology.
526//
527// It cannot overflow: every lot's remaining quantity is non-negative and their
528// sum is bounded by recvQty, which [Pool.Receive] refused to let overflow.
529func (p *Pool) QtyOnHand() int64 {
530 total := int64(0)
531 for _, l := range p.lots {
532 total += l.Remaining()
533 }
534 return total
535}
536
537// CostOnHand walks the live lots and sums what they are carried at: the
538// inventory value a realm reports on its balance sheet. Derived, for the same
539// reason as [Pool.QtyOnHand].
540//
541// It returns an error because a lot that failed validation makes the sum
542// meaningless, and because the running total is overflow-checked. That check
543// is believed unreachable — the sum is bounded by recvCost, whose own
544// accumulation Receive guards — and it is written anyway, because "unreachable"
545// is an argument about code that may be edited and the cost of being wrong
546// here is a negative inventory value.
547func (p *Pool) CostOnHand() (int64, error) {
548 total := int64(0)
549 for _, l := range p.lots {
550 c, err := l.RemainingCost()
551 if err != nil {
552 return 0, err
553 }
554 if total > maxInt64-c {
555 return 0, ErrOverflow
556 }
557 total += c
558 }
559 return total, nil
560}
561
562// Receive records a receipt of qty units costing cost in total.
563//
564// Under FIFO and LIFO it appends a lot, refusing with [ErrPoolFull] at the
565// pool's cap. Under WAVG it merges into the single live lot, re-anchoring it:
566// the merged lot carries the remaining quantity plus qty, the remaining cost
567// plus cost, and a consumed count of zero. See the package doc on why the
568// re-anchor is forced rather than chosen.
569//
570// The merge is where WAVG can refuse what the other two methods accept. A
571// merged lot must satisfy the same precondition every lot does,
572// `Cost <= maxInt64/Qty`, and two separately-held lots each satisfying it do
573// not imply their merge does. That returns [ErrOverflow] and leaves the pool
574// exactly as it was, so the receipt is refused rather than partly applied —
575// but a realm must handle it, because under WAVG it is a property of the
576// *history* and not only of the arguments.
577//
578// Nothing is committed until every check has passed: on any error the pool is
579// untouched, including its counters.
580func (p *Pool) Receive(qty, cost int64) error {
581 if !p.method.Valid() {
582 return ErrBadMethod
583 }
584 if qty <= 0 {
585 return ErrBadQty
586 }
587 if cost < 0 {
588 return ErrBadCost
589 }
590 if p.nextSeq == maxInt64 {
591 return ErrSeqExhausted
592 }
593 if p.recvQty > maxInt64-qty {
594 return ErrOverflow
595 }
596 if p.recvCost > maxInt64-cost {
597 return ErrOverflow
598 }
599
600 // Stage the lot the receipt produces, without touching the pool.
601 var staged []Lot
602 if p.method == WAVG && len(p.lots) > 0 {
603 // WAVG holds at most one lot by construction — the method is
604 // immutable and this branch is the only way a second one could
605 // arrive — so merging into index 0 is merging into the only lot.
606 // The guard is kept because that argument is about the whole
607 // package rather than about this function.
608 if len(p.lots) != 1 {
609 return ErrBadMethod
610 }
611 cur := p.lots[0]
612 rem := cur.Remaining()
613 remCost, err := cur.RemainingCost()
614 if err != nil {
615 return err
616 }
617 if rem > maxInt64-qty {
618 return ErrOverflow
619 }
620 if remCost > maxInt64-cost {
621 return ErrOverflow
622 }
623 merged := Lot{Qty: rem + qty, Cost: remCost + cost, Seq: cur.Seq}
624 if err := merged.check(); err != nil {
625 return err
626 }
627 staged = []Lot{merged}
628 } else {
629 if len(p.lots) >= p.maxLots {
630 return ErrPoolFull
631 }
632 fresh, err := NewLot(qty, cost)
633 if err != nil {
634 return err
635 }
636 fresh.Seq = p.nextSeq
637 staged = make([]Lot, 0, len(p.lots)+1)
638 staged = append(staged, p.lots...)
639 staged = append(staged, fresh)
640 }
641
642 // Commit.
643 p.lots = staged
644 p.nextSeq++
645 p.recvQty += qty
646 p.recvCost += cost
647 return nil
648}
649
650// Consume ships qty units and returns what they cost.
651//
652// The cost is derived from the lots the units came out of, in the pool's
653// declared order, and is never a parameter. Lots that the shipment exhausts
654// are dropped, which is cost-neutral because [Lot.CostAt] is exact at Qty.
655//
656// It is all-or-nothing in both senses. A shipment larger than
657// [Pool.QtyOnHand] is [ErrInsufficient] with the pool untouched — there is no
658// partial fill — and the whole effect is staged on a copy and committed only
659// after the last lot has been taken from successfully, because an error return
660// does not revert a Gno transaction. A pool left half-consumed with its
661// counters unmoved would state a cost on hand its lots contradict, detectable
662// only by a cross-foot.
663func (p *Pool) Consume(qty int64) (int64, error) {
664 if !p.method.Valid() {
665 return 0, ErrBadMethod
666 }
667 if qty <= 0 {
668 return 0, ErrBadQty
669 }
670 if qty > p.QtyOnHand() {
671 return 0, ErrInsufficient
672 }
673
674 staged := make([]Lot, len(p.lots))
675 copy(staged, p.lots)
676
677 total := int64(0)
678 left := qty
679 for _, i := range p.order() {
680 if left == 0 {
681 break
682 }
683 avail := staged[i].Remaining()
684 if avail <= 0 {
685 continue
686 }
687 take := avail
688 if left < take {
689 take = left
690 }
691 advanced, charge, err := staged[i].Take(take)
692 if err != nil {
693 return 0, err
694 }
695 // Cannot overflow: the sum of charges is bounded by the pool's
696 // cost on hand, itself bounded by recvCost. Checked anyway, for
697 // the reason given on CostOnHand.
698 if total > maxInt64-charge {
699 return 0, ErrOverflow
700 }
701 staged[i] = advanced
702 total += charge
703 left -= take
704 }
705 if left != 0 {
706 // Unreachable: the QtyOnHand check above established that the live
707 // lots hold at least qty units. Reported rather than ignored,
708 // because reaching it would mean QtyOnHand and the lots disagree,
709 // which is exactly what a cross-foot exists to catch.
710 return 0, ErrInsufficient
711 }
712
713 // Checked rather than argued, and placed before the first commit so a
714 // refusal still leaves the pool exactly as it was.
715 //
716 // The invariants consQty <= recvQty and chgCost <= recvCost do hold over
717 // this package's public API — both counters are unexported with no setter,
718 // and Receive refuses a receipt at either ceiling — so neither add can
719 // overflow today. That is an argument about the *current* call graph, and
720 // it is the same argument CostOnHand declines to rest on twenty lines up:
721 // "unreachable" is a claim about code that may be edited, and these two
722 // adds were the only running sums in the package without a guard in front
723 // of them. The cost of being wrong is a negative quantity consumed and a
724 // negative cost of goods sold, which a cross-foot would then report as the
725 // pool's own failure.
726 if p.consQty > maxInt64-qty {
727 return 0, ErrOverflow
728 }
729 if p.chgCost > maxInt64-total {
730 return 0, ErrOverflow
731 }
732
733 // Commit.
734 p.lots = dropExhausted(staged)
735 p.consQty += qty
736 p.chgCost += total
737 return total, nil
738}
739
740// order returns the indices of p.lots in consumption order. FIFO and WAVG
741// consume in receipt order; LIFO reverses it. WAVG holds one lot, so the two
742// orders coincide for it and it is grouped with FIFO rather than given a case
743// that could never be distinguished by a test.
744func (p *Pool) order() []int {
745 out := make([]int, len(p.lots))
746 for i := range p.lots {
747 if p.method == LIFO {
748 out[i] = len(p.lots) - 1 - i
749 } else {
750 out[i] = i
751 }
752 }
753 return out
754}
755
756// dropExhausted returns the lots with the exhausted ones removed, preserving
757// receipt order.
758//
759// It is cost-neutral, and that is a property of [Lot.CostAt] rather than of
760// this function: an exhausted lot has Consumed == Qty, so its consumed cost is
761// Cost*Qty/Qty — exactly Cost, in integers, with no remainder — and its
762// remaining cost is therefore exactly zero. Dropping it removes a zero from
763// the sum [Pool.CostOnHand] walks. Were the cost function inexact at the
764// endpoint, this housekeeping would quietly destroy value, which is why the
765// endpoint is asserted in the suite rather than assumed here.
766//
767// An **invalid** lot is not dropped. [Lot.Exhausted] requires validity, so a
768// malformed lot survives housekeeping and keeps failing [Pool.CostOnHand],
769// rather than being silently swept out of a pool whose counters still count it.
770func dropExhausted(in []Lot) []Lot {
771 out := make([]Lot, 0, len(in))
772 for _, l := range in {
773 if l.Exhausted() {
774 continue
775 }
776 out = append(out, l)
777 }
778 return out
779}
780
781// CrossFootCost reports the pool's cost identity:
782//
783// cost received == cost charged out + cost on hand
784//
785// The left side and the first right-hand term are cumulative counters; the
786// second is a walk of the live lots. So this compares two independent
787// derivations, and it is the strong one of the pair: it is what proves the WAVG
788// re-anchor loses nothing, that dropping exhausted lots is cost-neutral, and
789// that no shipment charged cost that was never received.
790//
791// foots is false if the identity fails **or if the walk failed**, and onHand is
792// zero in the second case. A caller that wants to tell those apart calls
793// [Pool.CostOnHand] directly; a caller that only wants to know whether the
794// sub-ledger is sound does not need to, because neither is sound.
795func (p *Pool) CrossFootCost() (received, charged, onHand int64, foots bool) {
796 onHand, err := p.CostOnHand()
797 if err != nil {
798 return p.recvCost, p.chgCost, 0, false
799 }
800 return p.recvCost, p.chgCost, onHand, p.recvCost == p.chgCost+onHand
801}
802
803// CrossFootQty reports the pool's quantity identity:
804//
805// quantity received == quantity consumed + quantity on hand
806//
807// It is the weaker of the pair and is **not implied** by [Pool.CrossFootCost],
808// which is why both exist. Costs are derived from quantities, so a quantity
809// that went missing in a way that preserved its own lot's cost arithmetic — a
810// lot dropped while still holding units it had charged nothing for, say —
811// foots perfectly on cost and fails here. A realm's invariant should assert
812// both.
813func (p *Pool) CrossFootQty() (received, consumed, onHand int64, foots bool) {
814 onHand = p.QtyOnHand()
815 return p.recvQty, p.consQty, onHand, p.recvQty == p.consQty+onHand
816}