Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}