Skip to main content

rustledger_core/inventory/
booking.rs

1//! Booking method implementations for Inventory.
2//!
3//! This module contains the implementation of all booking methods (STRICT,
4//! `STRICT_WITH_SIZE`, FIFO, LIFO, HIFO, AVERAGE, NONE) used to reduce positions
5//! from an inventory.
6
7use jiff::civil::Date as NaiveDate;
8use rust_decimal::Decimal;
9use rust_decimal::prelude::Signed;
10
11use smallvec::{SmallVec, smallvec};
12
13use super::{
14    BookingError, BookingMethod, BookingResult, Inventory, LotOrder, MatchedLots, OverflowError,
15};
16use crate::{Amount, Cost, CostSpec, Currency, Position};
17
18/// Compute weighted-average cost from a set of positions.
19///
20/// Returns `(avg_cost_per_unit, cost_currency)` or `None` if no positions have cost info.
21/// Returns `Err(CurrencyMismatch)` if positions have costs in different currencies.
22fn average_cost_from_positions(
23    positions: &[&Position],
24    total_units: Decimal,
25) -> Result<Option<(Decimal, Currency)>, BookingError> {
26    let mut total_cost = Decimal::ZERO;
27    let mut cost_currency: Option<Currency> = None;
28    let mut has_any_cost = false;
29
30    for pos in positions {
31        if let Some(cost) = &pos.cost {
32            has_any_cost = true;
33            if let Some(ref cc) = cost_currency {
34                if *cc != cost.currency {
35                    return Err(BookingError::CurrencyMismatch {
36                        expected: cc.clone(),
37                        got: cost.currency.clone(),
38                    });
39                }
40            } else {
41                cost_currency = Some(cost.currency.clone());
42            }
43            // Checked: the product needs the sum of its operands' digits, so
44            // it can leave range well below the ceiling (#1863).
45            total_cost = pos
46                .units
47                .number
48                .checked_mul(cost.number)
49                .and_then(|v| total_cost.checked_add(v))
50                .ok_or_else(|| {
51                    BookingError::Overflow(OverflowError {
52                        currency: cost.currency.clone(),
53                    })
54                })?;
55        }
56    }
57
58    if !has_any_cost || cost_currency.is_none() {
59        return Ok(None);
60    }
61
62    Ok(Some((total_cost / total_units, cost_currency.unwrap())))
63}
64
65/// A reduction computed from `&Inventory` but not yet applied.
66///
67/// The two variants mirror the two commit shapes the booking methods already
68/// had: the single-lot path maintains its caches incrementally, the multi-lot
69/// path rewrites lots and then rebuilds. Keeping them distinct means
70/// splitting preview from commit costs the commit path nothing.
71pub(super) enum ReductionPlan {
72    /// One lot reduced to `new_units`.
73    FromLot {
74        /// Index of the lot in `positions`.
75        idx: usize,
76        /// What that lot's units number becomes.
77        new_units: Decimal,
78    },
79    /// `(index, new units number)` pairs, applied in order.
80    Updates(SmallVec<[(usize, Decimal); 1]>),
81}
82
83/// What a `{*}` merge would do, computed without doing it.
84///
85/// The plan half of [`Inventory::reduce_merge`]; see [`Inventory::plan_merge`].
86struct MergePlan {
87    /// Slots that merge into the pool.
88    matching_indices: std::collections::HashSet<usize>,
89    /// Units held across those slots.
90    total_units: Decimal,
91    /// The pool's per-unit cost, or `None` when the lots carry no cost.
92    pool: Option<Amount>,
93}
94
95impl Inventory {
96    /// Try reducing positions without modifying the inventory.
97    ///
98    /// The read-only preview of [`Self::reduce`]: returns exactly what
99    /// `reduce` would return — the same matched lots and cost basis on
100    /// success, the same error otherwise — without mutating `self`.
101    ///
102    /// Implemented as `reduce` on a clone, so it is equivalent BY
103    /// CONSTRUCTION. It previously re-implemented every booking method's
104    /// selection logic in a parallel `try_*` tree, which drifted from the
105    /// mutating path in three places (STRICT ambiguity, NONE shorting, `{*}`
106    /// merge dispatch) — the recurring one-logic-two-paths class (#1648,
107    /// #1663, #1686). The clone is cheap: `positions` is an
108    /// `imbl::Vector`, so cloning is O(1) structural sharing and `reduce`'s
109    /// copy-on-write rebuild touches only the clone. The
110    /// `try_reduce_predicts_reduce` property test pins the equivalence.
111    ///
112    /// # Arguments
113    ///
114    /// * `units` - The units to reduce (negative for selling)
115    /// * `cost_spec` - Optional cost specification for matching lots
116    /// * `method` - The booking method to use
117    ///
118    /// # Errors
119    ///
120    /// Exactly the errors [`Self::reduce`] would return for the same input.
121    pub fn try_reduce(
122        &self,
123        units: &Amount,
124        cost_spec: Option<&CostSpec>,
125        method: BookingMethod,
126    ) -> Result<BookingResult, BookingError> {
127        let spec = cost_spec.cloned().unwrap_or_default();
128
129        // Planned methods answer from `&self`. The rest still preview by
130        // cloning — correct, just O(lots). Converting them is mechanical and
131        // follows the same shape; these are simply not the ones the profiling
132        // shapes exercise.
133        if spec.merge {
134            return self.clone().reduce(units, cost_spec, method);
135        }
136        match method {
137            BookingMethod::Strict => self.plan_strict(units, &spec).map(|(r, _)| r),
138            BookingMethod::Fifo => self
139                .plan_ordered(units, &spec, LotOrder::Date, false)
140                .map(|(r, _)| r),
141            BookingMethod::Lifo => self
142                .plan_ordered(units, &spec, LotOrder::Date, true)
143                .map(|(r, _)| r),
144            BookingMethod::Hifo => self.plan_hifo(units, &spec).map(|(r, _)| r),
145            BookingMethod::StrictWithSize => {
146                self.plan_strict_with_size(units, &spec).map(|(r, _)| r)
147            }
148            BookingMethod::Average | BookingMethod::None => {
149                self.clone().reduce(units, cost_spec, method)
150            }
151        }
152    }
153
154    /// Apply a [`ReductionPlan`] produced by one of the `plan_*` methods.
155    fn commit_plan(&mut self, plan: &ReductionPlan, units: &Amount) {
156        match plan {
157            ReductionPlan::FromLot { idx, new_units } => {
158                self.commit_from_lot(*idx, units, *new_units);
159            }
160            ReductionPlan::Updates(updates) => self.commit_updates(updates),
161        }
162    }
163
164    /// STRICT booking: require exactly one matching lot, unless either:
165    ///
166    /// - all matching lots are identical in cost, in which case the choice
167    ///   between them is irrelevant and we fall back to the same ordering as
168    ///   FIFO (oldest `cost.date` first — see [`Self::reduce_ordered`]), or
169    /// - the reduction exactly matches the total units available across the
170    ///   matching lots (full liquidation), in which case all of them may be
171    ///   drained together without ambiguity.
172    ///
173    /// If multiple lots with *different* costs match and the reduction does
174    /// not qualify for the full-liquidation exception — for example a
175    /// wildcard reduction `-5 AAPL {}` against an inventory holding both
176    /// `{150 USD}` and `{160 USD}` — the reduction is genuinely ambiguous and
177    /// we return `AmbiguousMatch`, matching Python beancount's
178    /// `AmbiguousMatchError` and the formal `STRICTCorrect.tla` specification.
179    ///
180    /// # The "interchangeable lots" heuristic
181    ///
182    /// We treat two matched lots as interchangeable when their `(cost.number,
183    /// cost.currency)` agree — the user-visible monetary identity. We
184    /// deliberately ignore `cost.date` and `cost.label`: the user's cost spec
185    /// could not have constrained those fields without naming them, so two
186    /// lots that differ only on date/label could not have been distinguished
187    /// by the spec the user wrote, and the date-ordered fallback is
188    /// unambiguous within that equivalence class.
189    ///
190    /// A stricter spec-derived check would compare each pair of matched lots
191    /// on every cost field the spec did *not* constrain. The simpler
192    /// number+currency check matches Python beancount's behavior for the
193    /// real-world cases we know about (see
194    /// `test_reduce_strict_multiple_match_with_identical_costs_uses_fifo` and
195    /// the `test_validate_multiple_lot_match_uses_fifo` integration test for
196    /// the same-cost-different-date case).
197    pub(super) fn reduce_strict(
198        &mut self,
199        units: &Amount,
200        spec: &CostSpec,
201    ) -> Result<BookingResult, BookingError> {
202        let (result, plan) = self.plan_strict(units, spec)?;
203        self.commit_plan(&plan, units);
204        Ok(result)
205    }
206
207    /// The read-only half of [`Self::reduce_strict`].
208    ///
209    /// STRICT is a dispatcher: one match delegates to the single-lot path,
210    /// financially-interchangeable or wholly-consumed multi-matches fall back
211    /// to FIFO ordering, and anything else is ambiguous and mutates nothing.
212    /// This mirrors that dispatch by delegating to the same two planners the
213    /// mutating path uses. See [`Self::plan_from_lot`] for why the selection
214    /// logic must not be duplicated into `try_reduce`.
215    pub(super) fn plan_strict(
216        &self,
217        units: &Amount,
218        spec: &CostSpec,
219    ) -> Result<(BookingResult, ReductionPlan), BookingError> {
220        // Candidates from the cost index when the spec names a per-unit cost,
221        // otherwise every lot — a spec without one can match anything. Both
222        // arms apply the SAME predicate, so the index never decides a match;
223        // it only narrows what the predicate is run on.
224        //
225        // That asymmetry is the whole safety argument, and it only runs one
226        // way. A STALE entry is harmless: it names a tombstone or a lot that
227        // no longer matches, and the predicate discards it. A MISSING entry is
228        // NOT: the lot is never offered to the predicate at all, so a
229        // reduction that should have matched it reports no matching lot or
230        // books as an augmentation and duplicates the position. Index
231        // maintenance is therefore a correctness obligation, not an
232        // optimization — `draining_a_lot_removes_it_from_the_cost_index` and
233        // the `add`/rebuild mutations are what hold it.
234        let matching_indices: Vec<usize> = match self.cost_candidates(units, spec) {
235            Some(slots) => slots
236                .into_iter()
237                .filter(|i| {
238                    // `get`, not `[]`: indexing a tombstone panics, and a
239                    // stale entry must not be able to crash on user data. With
240                    // `get` it is merely a wasted candidate, which the
241                    // predicate discards anyway.
242                    self.positions.get(*i).is_some_and(|p| {
243                        p.units.currency == units.currency
244                            && !p.is_empty()
245                            && p.can_reduce(units)
246                            && p.matches_cost_spec(spec)
247                    })
248                })
249                .collect(),
250            None => self
251                .positions
252                .iter_slots()
253                .filter(|(_, p)| {
254                    p.units.currency == units.currency
255                        && !p.is_empty()
256                        && p.can_reduce(units)
257                        && p.matches_cost_spec(spec)
258                })
259                .map(|(i, _)| i)
260                .collect(),
261        };
262
263        match matching_indices.len() {
264            0 => Err(BookingError::NoMatchingLot {
265                currency: units.currency.clone(),
266                cost_spec: spec.clone(),
267            }),
268            1 => {
269                let idx = matching_indices[0];
270                let (result, new_units) = self.plan_from_lot(idx, units)?;
271                Ok((result, ReductionPlan::FromLot { idx, new_units }))
272            }
273            n => {
274                // Two or more lots match, so the spec the user wrote does not
275                // name one. STRICT's contract is to refuse to guess, and this
276                // arm is deliberately the whole of it — the only escape is the
277                // total-match exception below.
278                //
279                // The one escape besides that is lots which are identical in
280                // EVERY cost field — number, currency, date and label. Those
281                // are indistinguishable by construction, so draining them in
282                // date order cannot be observed.
283                //
284                // This used to compare the number and currency ALONE, on the
285                // stated grounds that "the user could not have observed a
286                // different outcome" and that "beancount falls back to FIFO in
287                // that case". Both were wrong (#2097). Beancount's
288                // `booking_method_STRICT` has no fallback at all: more than one
289                // match is the total-match exception or an
290                // `AmbiguousMatchError`. And ignoring the date made the outcome
291                // very much observable — selling 16 of
292                // `4 GLOB {74.09, 2022-05-10}` + `16 GLOB {74.09, 2024-02-09}`
293                // leaves 4 GLOB dated 2024 under FIFO and 4 GLOB dated 2022
294                // under any other choice. Same cost basis, which is what made
295                // it quiet, but a different HOLDING PERIOD — and this codebase
296                // acts on holding periods, in `report capgains`'s short/long
297                // split and in the per-lot IRR eligibility predicate. That is a
298                // tax-visible decision, made silently, under the one booking
299                // method whose entire purpose is to make the user state it.
300                //
301                // Why keep the narrowed form rather than delete it outright:
302                // beancount's `Inventory` is keyed by `(currency, cost)`, so
303                // two buys of the same commodity at the same price on the same
304                // day are ONE position there and can never be ambiguous. Ours
305                // stays two lots, so deleting this arm would reject a ledger
306                // beancount accepts — a very ordinary one. Comparing the full
307                // cost reproduces beancount's observable behavior without
308                // changing how positions are stored; merging them at `add`
309                // would be the more faithful model and a much larger change
310                // (`Inventory::len` counts lots, and `currency_accounts`
311                // branches on it).
312                //
313                // The user disambiguates by naming the lot: `{74.09 USD,
314                // 2022-05-10}`, a label, or an account booked FIFO/HIFO if
315                // they genuinely do not care which goes.
316                let first_cost = self.positions[matching_indices[0]].cost.as_ref();
317                let all_indistinguishable = matching_indices
318                    .iter()
319                    .skip(1)
320                    .all(|&i| self.positions[i].cost.as_ref() == first_cost);
321
322                if all_indistinguishable {
323                    let (result, updates) =
324                        self.plan_ordered(units, spec, LotOrder::Date, false)?;
325                    return Ok((result, ReductionPlan::Updates(updates)));
326                }
327
328                // Total match exception: if the reduction equals the sum of all
329                // matching lots, every matched lot is consumed, so no lot
330                // survives to carry a date and the choice cannot be observed.
331                // Beancount has this same exception, and for the same reason.
332                let total_units: Decimal = matching_indices
333                    .iter()
334                    .map(|&i| self.positions[i].units.number.abs())
335                    .sum();
336                if total_units == units.number.abs() {
337                    let (result, updates) =
338                        self.plan_ordered(units, spec, LotOrder::Date, false)?;
339                    return Ok((result, ReductionPlan::Updates(updates)));
340                }
341
342                Err(BookingError::AmbiguousMatch {
343                    num_matches: n,
344                    currency: units.currency.clone(),
345                })
346            }
347        }
348    }
349
350    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
351    /// `STRICT_WITH_SIZE`: an explicit cost, disambiguated by lot size.
352    ///
353    /// Planned from `&self` so `try_reduce` can preview it without copying the
354    /// inventory. It used to be reachable only through `self.clone().reduce()`
355    /// — an O(lots) copy per reducing posting, which is quadratic across a
356    /// ledger and was most of this method's cost (#2091). The conversion is the
357    /// one #2061 left as "mechanical" when it split STRICT, FIFO and LIFO.
358    pub(super) fn plan_strict_with_size(
359        &self,
360        units: &Amount,
361        spec: &CostSpec,
362    ) -> Result<(BookingResult, ReductionPlan), BookingError> {
363        // Narrow through the cost index before filtering, the way `plan_strict`
364        // does. This walked every slot in the account on every reduction. A
365        // spec naming a cost has a handful of candidates; one that names none
366        // still scans, because it can match anything.
367        let candidates: Vec<usize> = self.cost_candidates(units, spec).unwrap_or_else(|| {
368            self.positions
369                .iter_slots()
370                .filter(|(_, p)| p.units.currency == units.currency)
371                .map(|(i, _)| i)
372                .collect()
373        });
374        let matching_indices: Vec<usize> = candidates
375            .into_iter()
376            .filter(|&i| {
377                self.positions.get(i).is_some_and(|p| {
378                    p.units.currency == units.currency
379                        && !p.is_empty()
380                        && p.can_reduce(units)
381                        && p.matches_cost_spec(spec)
382                })
383            })
384            .collect();
385
386        let from_lot = |idx: usize| {
387            self.plan_from_lot(idx, units)
388                .map(|(result, new_units)| (result, ReductionPlan::FromLot { idx, new_units }))
389        };
390
391        match matching_indices.len() {
392            0 => Err(BookingError::NoMatchingLot {
393                currency: units.currency.clone(),
394                cost_spec: spec.clone(),
395            }),
396            1 => from_lot(matching_indices[0]),
397            n => {
398                // A lot of exactly the reduction's size disambiguates. When
399                // SEVERAL do, the OLDEST wins — beancount sorts the size
400                // matches by `cost.date` and takes the first, and the choice
401                // is observable in both the basis realized and the holding
402                // period of whatever survives.
403                //
404                // This used to take the first candidate in slot order, which
405                // is insertion order. That is usually date order and so
406                // usually agreed by accident, but a lot carrying an explicit
407                // cost date (`{100.00 USD, 2030-01-01}`) is inserted when its
408                // transaction is booked and dated whenever the user said. Buy
409                // 10 X {100.00, 2030-01-01} then 10 X {200.00, 2020-01-01} and
410                // sell 10 X {}: beancount sells the 2020 lot and leaves 1000
411                // USD of basis, slot order sells the 2030 lot and leaves 2000.
412                // Neither reports anything.
413                //
414                // Ties break on slot index so the result stays deterministic
415                // when two size matches share a date; `None` dates sort last,
416                // since a booked lot always has one and an unbooked lot is not
417                // the one the user meant.
418                let exact = matching_indices
419                    .iter()
420                    .copied()
421                    .filter(|&i| self.positions[i].units.number.abs() == units.number.abs())
422                    .min_by_key(|&i| {
423                        (
424                            self.positions[i]
425                                .cost
426                                .as_ref()
427                                .and_then(|c| c.date)
428                                .map_or((1, NaiveDate::MAX), |d| (0, d)),
429                            i,
430                        )
431                    });
432                if let Some(idx) = exact {
433                    return from_lot(idx);
434                }
435                // Total match exception: selling the whole matched inventory
436                // makes the choice of lot irrelevant.
437                let total_units: Decimal = matching_indices
438                    .iter()
439                    .map(|&i| self.positions[i].units.number.abs())
440                    .sum();
441                if total_units == units.number.abs() {
442                    let (result, updates) =
443                        self.plan_ordered(units, spec, LotOrder::Date, false)?;
444                    return Ok((result, ReductionPlan::Updates(updates)));
445                }
446                Err(BookingError::AmbiguousMatch {
447                    num_matches: n,
448                    currency: units.currency.clone(),
449                })
450            }
451        }
452    }
453
454    pub(super) fn reduce_strict_with_size(
455        &mut self,
456        units: &Amount,
457        spec: &CostSpec,
458    ) -> Result<BookingResult, BookingError> {
459        let (result, plan) = self.plan_strict_with_size(units, spec)?;
460        self.commit_plan(&plan, units);
461        Ok(result)
462    }
463
464    pub(super) fn reduce_fifo(
465        &mut self,
466        units: &Amount,
467        spec: &CostSpec,
468    ) -> Result<BookingResult, BookingError> {
469        self.reduce_ordered(units, spec, false)
470    }
471
472    /// LIFO booking: reduce from newest lots first.
473    pub(super) fn reduce_lifo(
474        &mut self,
475        units: &Amount,
476        spec: &CostSpec,
477    ) -> Result<BookingResult, BookingError> {
478        self.reduce_ordered(units, spec, true)
479    }
480
481    /// HIFO booking: reduce from highest-cost lots first.
482    /// HIFO booking: take from the most expensive lots first.
483    ///
484    /// The plan half of the ordered walk with a cost key, which is all HIFO
485    /// ever was. It used to carry its own copy of that walk — scan every slot,
486    /// sort the survivors by cost, sum them for sufficiency, then take — which
487    /// is O(lots) per reduction and was 18s on a 20,000-transaction ledger
488    /// against FIFO's 0.27s (#2091). Sharing `plan_ordered` gives it the
489    /// maintained index, the early stop, and a plan half so `try_reduce` can
490    /// preview without cloning the inventory.
491    pub(super) fn plan_hifo(
492        &self,
493        units: &Amount,
494        spec: &CostSpec,
495    ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
496        self.plan_ordered(units, spec, LotOrder::CostDescending, false)
497    }
498
499    pub(super) fn reduce_hifo(
500        &mut self,
501        units: &Amount,
502        spec: &CostSpec,
503    ) -> Result<BookingResult, BookingError> {
504        let (result, updates) = self.plan_hifo(units, spec)?;
505        self.commit_updates(&updates);
506        Ok(result)
507    }
508
509    pub(super) fn reduce_ordered(
510        &mut self,
511        units: &Amount,
512        spec: &CostSpec,
513        reverse: bool,
514    ) -> Result<BookingResult, BookingError> {
515        let (result, updates) = self.plan_ordered(units, spec, LotOrder::Date, reverse)?;
516        self.commit_updates(&updates);
517        Ok(result)
518    }
519
520    /// The read-only half of [`Self::reduce_ordered`]: pick the lots, check
521    /// sufficiency, and compute what would be matched — without touching a
522    /// single position.
523    ///
524    /// Returns the result alongside `(index, new units number)` pairs for the
525    /// caller to apply. See [`Self::plan_from_lot`] for why the split exists
526    /// and why the selection logic must not be duplicated into `try_reduce`.
527    pub(super) fn plan_ordered(
528        &self,
529        units: &Amount,
530        spec: &CostSpec,
531        order: LotOrder,
532        reverse: bool,
533    ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
534        let mut remaining = units.number.abs();
535        let mut matched: MatchedLots = SmallVec::new();
536        let mut cost_basis = Decimal::ZERO;
537        let mut cost_currency = None;
538
539        // Candidates in FIFO order.
540        //
541        // `ordered_index` already holds this currency's lots — every one of
542        // them, cost-less included, since an empty spec matches those too — in
543        // (date, slot) order, so the common path neither scans every slot nor
544        // sorts per call. It did both, once per reduction, and `{}` is how
545        // FIFO sells are written — so that was the normal case (#2083).
546        //
547        // The predicate still runs per candidate: the index knows nothing
548        // about sign, emptiness or what this spec matches, and a stale entry
549        // for a drained lot is expected, since removal is best-effort.
550        let scanned: Option<Vec<usize>> =
551            if self.ordered_candidates(&units.currency, order).is_some() {
552                None
553            } else {
554                // No index — a shared snapshot, or one never rebuilt. Scanning is
555                // the only correct answer, and it is what this always did.
556                let mut all: Vec<usize> = self
557                    .positions
558                    .iter_slots()
559                    .filter(|(_, p)| p.units.currency == units.currency)
560                    .map(|(i, _)| i)
561                    .collect();
562                all.sort_by_key(|&i| self.order_key(order, i));
563                Some(all)
564            };
565        let candidates: &[usize] = match &scanned {
566            Some(all) => all,
567            None => self
568                .ordered_candidates(&units.currency, order)
569                .expect("the branch above proved it is Some"),
570        };
571
572        let keeps = |i: usize| {
573            self.positions.get(i).is_some_and(|p| {
574                p.units.currency == units.currency
575                    && !p.is_empty()
576                    && p.units.number.signum() != units.number.signum()
577                    && p.matches_cost_spec(spec)
578            })
579        };
580
581        // Sufficiency used to be checked up front, by summing every matching
582        // lot before taking any. That was needed when this function mutated;
583        // since the plan/commit split (#2061) it only computes and the caller
584        // commits, so exhausting the walk reports the same shortfall with the
585        // same total, having visited the same lots. The invariant it protected
586        // — a failed reduction leaves the inventory untouched — is structural
587        // now, and `booking_properties.rs` still pins it.
588        let mut updates: SmallVec<[(usize, Decimal); 1]> = SmallVec::new();
589        let mut seen_any = false;
590        let mut available_total = Decimal::ZERO;
591        let mut overflow: Option<OverflowError> = None;
592
593        let mut forward;
594        let mut backward;
595        let walk: &mut dyn Iterator<Item = usize> = if reverse {
596            backward = candidates.iter().rev().copied();
597            &mut backward
598        } else {
599            forward = candidates.iter().copied();
600            &mut forward
601        };
602
603        for idx in walk {
604            if !keeps(idx) {
605                continue;
606            }
607            seen_any = true;
608            let pos = &self.positions[idx];
609            available_total += pos.units.number.abs();
610            let available = pos.units.number.abs();
611            let take = remaining.min(available);
612
613            // Calculate cost basis for this portion (checked — see the
614            // matching site in the FIFO/LIFO ladder above).
615            if let Some(cost) = &pos.cost {
616                if cost_currency.is_none() {
617                    cost_currency = Some(cost.currency.clone());
618                }
619                // Recorded, not returned. Sufficiency used to be settled before
620                // any basis arithmetic ran, so a reduction that was BOTH short
621                // of units and unrepresentable reported the shortfall — the
622                // actionable half. Returning here would report the overflow
623                // instead, which is a behavior change nothing asked for.
624                match take
625                    .checked_mul(cost.number)
626                    .and_then(|v| cost_basis.checked_add(v))
627                {
628                    Some(v) => cost_basis = v,
629                    None => {
630                        overflow.get_or_insert_with(|| OverflowError {
631                            currency: cost.currency.clone(),
632                        });
633                    }
634                }
635            }
636
637            // Record what we matched
638            let (taken, _) = pos.split(take * pos.units.number.signum());
639            matched.push(taken);
640
641            // What the lot WOULD become. Recorded rather than applied so the
642            // preview path can run this same loop against `&self`.
643            let reduction = if units.number.is_sign_negative() {
644                -take
645            } else {
646                take
647            };
648            updates.push((idx, pos.units.number + reduction));
649
650            remaining -= take;
651
652            // Covered: stop here rather than walking the rest of the account.
653            // `available_total` is left partial on purpose — it feeds the
654            // shortfall message only, which is unreachable once the reduction
655            // is satisfied. Walking on to complete a number nobody reads is
656            // what made this O(lots): the spec is concrete by the time `apply`
657            // re-derives, so most later lots fail the predicate and the loop
658            // ran the cost comparison against every one of them.
659            if remaining.is_zero() {
660                break;
661            }
662        }
663
664        // No lot of this currency matched the spec at all.
665        if !seen_any {
666            return Err(BookingError::NoMatchingLot {
667                currency: units.currency.clone(),
668                cost_spec: spec.clone(),
669            });
670        }
671        // Lots matched but did not cover the reduction. Reported with the same
672        // total the up-front sum produced, because reaching here means the
673        // walk visited every matching lot.
674        if !remaining.is_zero() {
675            return Err(BookingError::InsufficientUnits {
676                currency: units.currency.clone(),
677                requested: units.number.abs(),
678                available: available_total,
679            });
680        }
681        // Only now: the reduction is satisfiable, so the arithmetic is what
682        // failed.
683        if let Some(error) = overflow {
684            return Err(BookingError::Overflow(error));
685        }
686
687        Ok((
688            BookingResult {
689                matched,
690                cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
691            },
692            updates,
693        ))
694    }
695
696    /// Apply `(index, new units)` pairs from a plan, then restore the caches.
697    ///
698    /// `retain` + `rebuild_index` exactly as the fused `reduce_ordered` did —
699    /// the multi-lot path always paid an O(lots) cache rebuild, and changing
700    /// that is a separate question from removing the preview's clone.
701    /// Apply a multi-lot plan, repairing the caches rather than rebuilding
702    /// them.
703    ///
704    /// This used to `retain` away the drained lots and then `rebuild_index()`,
705    /// which is two walks of every slot plus a rehash of every key — per
706    /// reduction. In a FIFO ledger, where reductions are frequent and lots
707    /// accumulate, that is the whole cost: 20k transactions took 8.9s, of
708    /// which 7.1s was the rebuild (#2083).
709    ///
710    /// `updates` already names every slot that changed, so the repair is
711    /// proportional to the lots the reduction touched rather than to the lots
712    /// the account holds. Same treatment `commit_from_lot` got in #2063, for
713    /// the same reason; this is the multi-lot half of that change.
714    fn commit_updates(&mut self, updates: &[(usize, Decimal)]) {
715        // Every update names a lot of the same currency, because every producer
716        // of `ReductionPlan::Updates` filters on `units.currency` before
717        // planning. This repair DEPENDS on that — it adjusts one currency's
718        // running total — where the old `rebuild_index()` recomputed them all
719        // and so could not notice the invariant being violated.
720        //
721        // Checked here rather than after the loop: by then the drained lots are
722        // tombstones, and indexing one panics.
723        debug_assert!(
724            updates.windows(2).all(|pair| {
725                self.positions[pair[0].0].units.currency == self.positions[pair[1].0].units.currency
726            }),
727            "commit_updates was given lots of more than one currency; the units \
728             cache is adjusted per currency and would silently drift",
729        );
730
731        let mut delta = Decimal::ZERO;
732        let mut currency = None;
733
734        for &(idx, new_units) in updates {
735            let previous = self.positions[idx].units.number;
736            delta += new_units - previous;
737            if currency.is_none() {
738                currency = Some(self.positions[idx].units.currency.clone());
739            }
740
741            // Drop the old classification before overwriting: a reduction can
742            // take a lot through zero and flip its sign bucket.
743            self.sign_index_bump(idx, -1);
744            self.positions[idx].units.number = new_units;
745            self.sign_index_bump(idx, 1);
746
747            if self.positions[idx].is_empty() {
748                self.sign_index_bump(idx, -1);
749                self.cost_index_remove(idx);
750                self.positions.remove(idx);
751                // Removal leaves a tombstone, so no surviving lot is
752                // renumbered and only the entry naming this lot has to go. An
753                // empty cost spec matches a cost-less position, so ordered
754                // selection can drain one and this map can name it.
755                self.units_cache
756                    .values_mut()
757                    .filter(|stats| stats.simple_slot == Some(idx))
758                    .for_each(|stats| stats.simple_slot = None);
759            }
760        }
761
762        // One adjustment for the whole plan: see the assertion at the top.
763        if let Some(currency) = currency
764            && let Some(stats) = self.units_cache.get_mut(&currency)
765        {
766            stats.total = crate::decimal::add_python_scale(stats.total, delta);
767        }
768    }
769
770    /// AVERAGE booking: merge all lots of the currency.
771    ///
772    /// Stricter than Python: beancount does NOT implement this method.
773    /// `beancount.parser.booking_method.booking_method_AVERAGE` raises
774    /// `AmbiguousMatchError("AVERAGE method is not supported")`, with the real
775    /// implementation left commented out ("DISABLED - This is the code for
776    /// AVERAGE, which is currently disabled"). `Booking.AVERAGE` exists in the
777    /// enum and is accepted in an `open` directive, so a ledger declaring it
778    /// parses and then books nothing.
779    ///
780    /// Two consequences worth knowing before changing anything here:
781    ///
782    /// * The compat oracle cannot referee this. There is no reference answer
783    ///   to diff against, so correctness rests on the definition — the merged
784    ///   lot's cost is the cost-weighted average of the lots it replaces — and
785    ///   on the two rledger surfaces agreeing.
786    /// * That is exactly why #1985 survived: BQL netted by lot key and produced
787    ///   a dangling negative position where reports produced the merged lot,
788    ///   and no differential test could see it. The internal parity guard
789    ///   (`query_report_realization_parity_test`) is what caught it.
790    pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
791        let matching: Vec<&Position> = self
792            .positions
793            .iter()
794            .filter(|p| p.units.currency == units.currency && !p.is_empty())
795            .collect();
796
797        let total_units: Decimal = matching
798            .iter()
799            .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
800            .ok_or_else(|| {
801                BookingError::Overflow(OverflowError {
802                    currency: units.currency.clone(),
803                })
804            })?;
805
806        if total_units.is_zero() {
807            return Err(BookingError::InsufficientUnits {
808                currency: units.currency.clone(),
809                requested: units.number.abs(),
810                available: Decimal::ZERO,
811            });
812        }
813
814        let reduction = units.number.abs();
815        if reduction > total_units.abs() {
816            return Err(BookingError::InsufficientUnits {
817                currency: units.currency.clone(),
818                requested: reduction,
819                available: total_units.abs(),
820            });
821        }
822
823        let avg = average_cost_from_positions(&matching, total_units)?;
824        let cost_basis = avg
825            .as_ref()
826            .map(|(avg_cost, currency)| {
827                reduction
828                    .checked_mul(*avg_cost)
829                    .map(|n| Amount::new(n, currency.clone()))
830                    .ok_or_else(|| {
831                        BookingError::Overflow(OverflowError {
832                            currency: currency.clone(),
833                        })
834                    })
835            })
836            .transpose()?;
837
838        // Build a position of `number` units of the reduced currency at the
839        // average cost (or costless if the lots had no cost).
840        let at_avg_cost = |number: Decimal| -> Position {
841            let amount = Amount::new(number, units.currency.clone());
842            match &avg {
843                Some((avg_cost, currency)) => {
844                    Position::with_cost(amount, Cost::new(*avg_cost, currency.clone()))
845                }
846                None => Position::simple(amount),
847            }
848        };
849
850        // A reduction under AVERAGE matches a SINGLE synthetic lot of the
851        // reduced quantity at the average cost, not every underlying lot.
852        // Returning the full lot set made the consumer (book.rs) expand the
853        // reduction into one posting per lot and remove the entire position
854        // (and book a garbage gain). The taken units carry the *inventory* sign
855        // (`total_units.signum()`), matching the FIFO/ordered convention — so
856        // covering a short (negative pool) yields a negative matched lot.
857        let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
858
859        let new_units = total_units + units.number;
860
861        // Remove all positions of this currency
862        self.positions
863            .retain(|p| p.units.currency != units.currency);
864
865        // Add back the remainder (if non-zero) at the average cost, so a later
866        // reduction sees the correct basis instead of a costless position.
867        if !new_units.is_zero() {
868            self.positions.push(at_avg_cost(new_units));
869        }
870
871        self.rebuild_index();
872
873        Ok(BookingResult {
874            matched,
875            cost_basis,
876        })
877    }
878
879    /// Collapse every cost-bearing lot of each currency into a single
880    /// weighted-average-cost lot. Cost-less (cash) positions are left untouched.
881    ///
882    /// This realizes the balance of an AVERAGE-booked account, where all lots of
883    /// a commodity share one running cost. The journal keeps the real per-lot
884    /// costs; only this realized view merges them (matching hledger's pool
885    /// model). A currency whose lots net to zero is removed; a currency whose
886    /// lots have mismatched cost currencies is left untouched.
887    ///
888    /// # Errors
889    ///
890    /// [`OverflowError`] when a currency's lots sum outside `rust_decimal`'s
891    /// range. The merged view is a realized balance, so a clamped total would
892    /// be rendered as an exact position (#1863).
893    pub fn merge_average(&mut self) -> Result<(), OverflowError> {
894        let currencies: std::collections::BTreeSet<Currency> = self
895            .positions
896            .iter()
897            .filter(|p| p.cost.is_some())
898            .map(|p| p.units.currency.clone())
899            .collect();
900
901        for currency in currencies {
902            let (total_units, avg) = {
903                let matching: Vec<&Position> = self
904                    .positions
905                    .iter()
906                    .filter(|p| p.units.currency == currency && p.cost.is_some())
907                    .collect();
908                let total_units: Decimal = matching
909                    .iter()
910                    .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
911                    .ok_or_else(|| OverflowError {
912                        currency: currency.clone(),
913                    })?;
914                let avg = if total_units.is_zero() {
915                    None
916                } else {
917                    average_cost_from_positions(&matching, total_units)
918                        .ok()
919                        .flatten()
920                };
921                (total_units, avg)
922            };
923
924            // Couldn't average a non-zero position (cost-currency mismatch):
925            // leave its lots untouched rather than corrupt them.
926            if !total_units.is_zero() && avg.is_none() {
927                continue;
928            }
929
930            self.positions
931                .retain(|p| !(p.units.currency == currency && p.cost.is_some()));
932            if let Some((avg_cost, cost_currency)) = avg {
933                self.positions.push(Position::with_cost(
934                    Amount::new(total_units, currency.clone()),
935                    Cost::new(avg_cost, cost_currency),
936                ));
937            }
938        }
939        self.rebuild_index();
940        Ok(())
941    }
942
943    /// What `{*}` would merge, computed from `&self`.
944    ///
945    /// The selection half of [`Self::reduce_merge`], split out so the pool cost
946    /// can be known WITHOUT building it (#2068). `reduce_merge` is the only
947    /// mutating caller and it goes through here, so there is one implementation
948    /// of "which lots merge and at what average" — the duplication that the
949    /// plan/commit split (#2061) exists to prevent.
950    ///
951    /// `pool` is `None` when the matched lots carry no cost, which is
952    /// `reduce_merge`'s AVERAGE fallback rather than an error.
953    fn plan_merge(&self, units: &Amount) -> Result<MergePlan, BookingError> {
954        // Only merge lots with opposite sign (same as other reduce methods).
955        // This prevents accidentally netting long and short positions.
956        let matching: Vec<(usize, &Position)> = self
957            .positions
958            .iter_slots()
959            .filter(|(_, p)| {
960                p.units.currency == units.currency
961                    && !p.is_empty()
962                    && p.units.number.is_sign_positive() != units.number.is_sign_positive()
963            })
964            .collect();
965
966        if matching.is_empty() {
967            return Err(BookingError::InsufficientUnits {
968                currency: units.currency.clone(),
969                requested: units.number.abs(),
970                available: Decimal::ZERO,
971            });
972        }
973
974        let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
975        let reduction = units.number.abs();
976
977        if reduction > total_units.abs() {
978            return Err(BookingError::InsufficientUnits {
979                currency: units.currency.clone(),
980                requested: reduction,
981                available: total_units.abs(),
982            });
983        }
984
985        let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
986        let pool = average_cost_from_positions(&matching_refs, total_units)?
987            .map(|(number, currency)| Amount::new(number, currency));
988
989        Ok(MergePlan {
990            matching_indices: matching.iter().map(|(i, _)| *i).collect(),
991            total_units,
992            pool,
993        })
994    }
995
996    /// The per-unit pool cost `{*}` would produce here, without producing it.
997    ///
998    /// `Ok(None)` means there is no pool cost to compare against — cost-less
999    /// lots, where `{*}` degrades to AVERAGE. Errors are the ones the reduction
1000    /// itself would raise (no matching lots, insufficient units); a caller
1001    /// checking a precondition should let the reduction report them rather than
1002    /// pre-empting it, so that one function keeps owning the message.
1003    ///
1004    /// Exists so `BookingEngine::apply` can verify a carried `{*}` against the
1005    /// cost booking recorded BEFORE the merge mutates anything (#2068).
1006    pub fn merged_pool_cost(&self, units: &Amount) -> Result<Option<Amount>, BookingError> {
1007        Ok(self.plan_merge(units)?.pool)
1008    }
1009
1010    /// Cost merge `{*}`: merge all lots of the currency into a single
1011    /// weighted-average-cost lot, then reduce from it.
1012    ///
1013    /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
1014    /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
1015    pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1016        let plan = self.plan_merge(units)?;
1017        let MergePlan {
1018            matching_indices,
1019            total_units,
1020            pool: Some(pool),
1021        } = plan
1022        else {
1023            // Cost-less lots: there is no pool to build (`plan_merge` says so),
1024            // so `{*}` degrades to AVERAGE, exactly as before the plan split.
1025            return self.reduce_average(units);
1026        };
1027        let reduction = units.number.abs();
1028        let (avg_cost, cost_currency) = (pool.number, pool.currency);
1029
1030        let cost_basis = Some(Amount::new(
1031            reduction.checked_mul(avg_cost).ok_or_else(|| {
1032                BookingError::Overflow(OverflowError {
1033                    currency: cost_currency.clone(),
1034                })
1035            })?,
1036            cost_currency.clone(),
1037        ));
1038
1039        // Return a single synthetic matched position representing the merged lot.
1040        // This prevents the booking engine from expanding the posting into multiple
1041        // postings (one per original lot), which would be incorrect for {*}.
1042        let make_avg_cost = || Cost {
1043            number: avg_cost,
1044            currency: cost_currency.clone(),
1045            date: None,
1046            label: None,
1047        };
1048
1049        let matched: MatchedLots = smallvec![Position::with_cost(
1050            Amount::new(units.number.abs(), units.currency.clone()),
1051            make_avg_cost(),
1052        )];
1053
1054        // Remove all matching lots of this currency
1055        self.positions
1056            .retain_slots(|slot, _| !matching_indices.contains(&slot));
1057
1058        // Add back a single merged lot with the remainder
1059        let remaining = total_units + units.number; // units.number is negative for reductions
1060        if !remaining.is_zero() {
1061            self.positions.push(Position::with_cost(
1062                Amount::new(remaining, units.currency.clone()),
1063                make_avg_cost(),
1064            ));
1065        }
1066
1067        self.rebuild_index();
1068
1069        Ok(BookingResult {
1070            matched,
1071            cost_basis,
1072        })
1073    }
1074
1075    /// NONE booking: reduce without matching lots.
1076    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1077        // For NONE booking, we just reduce the total without caring about lots
1078        let total_units = self.units(&units.currency);
1079
1080        // Check we have enough in the right direction
1081        if total_units.signum() == units.number.signum() || total_units.is_zero() {
1082            // This is an augmentation, not a reduction - just add it
1083            self.add(Position::simple(units.clone()))?;
1084            return Ok(BookingResult {
1085                matched: SmallVec::new(),
1086                cost_basis: None,
1087            });
1088        }
1089
1090        let available = total_units.abs();
1091        let requested = units.number.abs();
1092
1093        if requested > available {
1094            // NONE performs no booking, so shorts are always allowed —
1095            // matching beancount's NONE semantics and NONECorrect.tla. This
1096            // arm previously returned InsufficientUnits, which made the
1097            // outcome depend on whether zero was crossed in one step (0 → -2
1098            // was allowed above; +1 → -1 was rejected here). Found by the
1099            // TLA+ behavior-replay suite (#1686): consume everything
1100            // available, then carry the remainder as a negative (short)
1101            // simple position.
1102            let sign = units.number.signum();
1103            let consumed = Amount::new(available * sign, units.currency.clone());
1104            let result = self.reduce_ordered(&consumed, &CostSpec::default(), false)?;
1105            self.add(Position::simple(Amount::new(
1106                (requested - available) * sign,
1107                units.currency.clone(),
1108            )))?;
1109            return Ok(result);
1110        }
1111
1112        // Reduce positions proportionally (simplified: just reduce first matching)
1113        self.reduce_ordered(units, &CostSpec::default(), false)
1114    }
1115
1116    /// Reduce from a specific lot.
1117    pub(super) fn plan_from_lot(
1118        &self,
1119        idx: usize,
1120        units: &Amount,
1121    ) -> Result<(BookingResult, Decimal), BookingError> {
1122        let pos = &self.positions[idx];
1123        let available = pos.units.number.abs();
1124        let requested = units.number.abs();
1125
1126        if requested > available {
1127            return Err(BookingError::InsufficientUnits {
1128                currency: units.currency.clone(),
1129                requested,
1130                available,
1131            });
1132        }
1133
1134        // Calculate cost basis
1135        let cost_basis = pos
1136            .cost
1137            .as_ref()
1138            .map(|c| {
1139                c.total_cost(requested).ok_or_else(|| {
1140                    BookingError::Overflow(OverflowError {
1141                        currency: c.currency.clone(),
1142                    })
1143                })
1144            })
1145            .transpose()?;
1146
1147        // Record matched
1148        let (matched, _) = pos.split(requested * pos.units.number.signum());
1149
1150        // Python scale rule, same as `Inventory::add` — see
1151        // `crate::decimal::add_python_scale`. A reduction that brings a lot
1152        // through zero would otherwise drop the scale here while `add` kept
1153        // it, so the same lot would render differently depending on whether
1154        // it was last touched by an add or a reduce.
1155        let new_units = crate::decimal::add_python_scale(pos.units.number, units.number);
1156
1157        Ok((
1158            BookingResult {
1159                matched: smallvec![matched],
1160                cost_basis,
1161            },
1162            new_units,
1163        ))
1164    }
1165
1166    /// The mutating half of [`Self::reduce_from_lot`], applying a
1167    /// [`Self::plan_from_lot`] result.
1168    ///
1169    /// Keeps the incremental cache maintenance the single-lot path always
1170    /// had: a full `rebuild_index` here would be O(lots) on the commit path
1171    /// that this split is meant to keep cheap.
1172    fn commit_from_lot(&mut self, idx: usize, units: &Amount, new_units: Decimal) {
1173        let currency = self.positions[idx].units.currency.clone();
1174        let new_pos = Position {
1175            units: Amount::new(new_units, currency.clone()),
1176            cost: self.positions[idx].cost.clone(),
1177        };
1178        // Drop the old classification before overwriting: a reduction can take
1179        // a lot through zero and flip its sign bucket.
1180        self.sign_index_bump(idx, -1);
1181        self.positions[idx] = new_pos;
1182        self.sign_index_bump(idx, 1);
1183
1184        // Update units cache incrementally (units.number is negative for reductions)
1185        if let Some(stats) = self.units_cache.get_mut(&currency) {
1186            stats.total = crate::decimal::add_python_scale(stats.total, units.number);
1187        }
1188
1189        // Remove if empty, then repair `simple_index`.
1190        if self.positions[idx].is_empty() {
1191            self.sign_index_bump(idx, -1);
1192            self.cost_index_remove(idx);
1193            self.positions.remove(idx);
1194
1195            // Removing shifts every later position down one, so the stored
1196            // indices past `idx` are now off by one. Patch the MAP rather than
1197            // rescanning the positions to rebuild it.
1198            //
1199            // `simple_index` holds at most one entry per currency — cost-less
1200            // lots of a currency merge into a single lot — so this is O(number
1201            // of currencies), against O(lots) for the rescan it replaces. On
1202            // an investment account, where every lot carries a cost and the
1203            // map is EMPTY, the rescan walked the entire lot list to find
1204            // nothing at all; it grew 164x for 10x the input on the
1205            // `investment` profiling shape.
1206            //
1207            // Nothing shifted: removal leaves a tombstone, so every
1208            // surviving lot keeps its slot. Only the entry naming the removed
1209            // lot has to go — and it CAN name it, because an empty cost spec
1210            // matches a cost-less position (`matches_cost_spec`:
1211            // `(None, true) => true`), so STRICT can select and drain one.
1212            //
1213            // Before tombstones this also decremented the later entries to
1214            // follow the shift. Keeping that now would renumber indices that
1215            // did not move, pointing `add`'s merge at a tombstone — which is
1216            // exactly what `removing_a_lot_repairs_the_index_of_a_later_cost_less_lot`
1217            // caught.
1218            self.units_cache
1219                .values_mut()
1220                .filter(|stats| stats.simple_slot == Some(idx))
1221                .for_each(|stats| stats.simple_slot = None);
1222        }
1223    }
1224}
1225
1226#[cfg(test)]
1227mod reduction_tests {
1228    //! Direct unit tests for the read-only `try_reduce_*` booking paths.
1229    //!
1230    //! These pin exact cost-basis, lot selection, and guard behavior so
1231    //! the lot-reduction mutants surfaced by the #1309 audit are killed
1232    //! (the public mutating `reduce_*` path was covered indirectly, but
1233    //! the `try_reduce_*` preview path had no direct assertions).
1234    use super::LotOrder;
1235    use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
1236    use rust_decimal::Decimal;
1237    use rust_decimal_macros::dec;
1238
1239    fn d(n: i64) -> Decimal {
1240        Decimal::from(n)
1241    }
1242
1243    /// A cost-bearing lot of `units` STK at `cost` USD, dated 2024-01-`day`.
1244    fn lot(units: i64, cost: i64, day: u32) -> Position {
1245        Position::with_cost(
1246            Amount::new(d(units), "STK"),
1247            Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
1248        )
1249    }
1250
1251    /// A multi-lot reduction removes what it drained, and nothing else.
1252    ///
1253    /// `commit_updates` used to `retain(|p| !p.is_empty())` over the whole
1254    /// inventory, so any reduction that crossed two lots also swept away every
1255    /// unrelated zero-unit position as a side effect. Zero-unit lots are not
1256    /// scrap: `Inventory::len` counts them and `currency_accounts` branches on
1257    /// `len() == 1`, so sweeping them changed what other surfaces reported
1258    /// depending on whether a reduction happened to be multi-lot.
1259    #[test]
1260    fn a_multi_lot_reduction_leaves_unrelated_empty_lots_alone() {
1261        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1262        // An unrelated zero-unit position in another commodity. Netted to
1263        // zero rather than added as zero: `add` drops a zero-unit position
1264        // outright, so the only way one exists is a cost-less lot merging
1265        // through zero — which is exactly the case `Inventory::len`'s contract
1266        // is about.
1267        inv.add(Position::simple(Amount::new(d(5), "ZERO")))
1268            .expect("fixture fits in Decimal");
1269        inv.add(Position::simple(Amount::new(d(-5), "ZERO")))
1270            .expect("fixture fits in Decimal");
1271        let before = inv.len();
1272
1273        // Cross both STK lots, draining the first.
1274        inv.reduce(
1275            &Amount::new(d(-15), "STK"),
1276            Some(&CostSpec::default()),
1277            BookingMethod::Fifo,
1278        )
1279        .expect("15 of 20 units are there");
1280
1281        assert_eq!(
1282            inv.len(),
1283            before - 1,
1284            "exactly the drained lot should be gone: the untouched zero-unit \
1285             position is not this reduction's business",
1286        );
1287    }
1288
1289    /// Insufficiency outranks overflow, as it did before the walk was made lazy.
1290    #[test]
1291    fn insufficient_units_are_reported_even_when_the_basis_would_overflow() {
1292        // A lot whose cost basis cannot be represented, and a reduction asking
1293        // for more units than exist.
1294        let huge = Decimal::MAX / d(2);
1295        let mut inv = Inventory::new();
1296        inv.add(Position::with_cost(
1297            Amount::new(d(10), "STK"),
1298            Cost::new(huge, "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1299        ))
1300        .expect("fixture fits in Decimal");
1301
1302        let err = inv
1303            .plan_ordered(
1304                &Amount::new(d(-99), "STK"),
1305                &CostSpec::default(),
1306                LotOrder::Date,
1307                false,
1308            )
1309            .expect_err("99 units are not there");
1310        assert!(
1311            matches!(err, crate::BookingError::InsufficientUnits { .. }),
1312            "the shortfall is the actionable error, not the arithmetic it would \
1313             have done on the way: {err:?}",
1314        );
1315    }
1316
1317    /// HIFO takes costed lots before cost-less ones.
1318    ///
1319    /// A cost-less lot has no cost to compare, and an empty cost spec matches
1320    /// it, so it is a candidate. The scan this replaced counted it as zero
1321    /// before reversing, which put it last; ordering on `Option<Decimal>`
1322    /// would put it FIRST, because `None` sorts before `Some`. Same lots,
1323    /// opposite lot chosen.
1324    #[test]
1325    fn hifo_takes_costed_lots_before_costless_ones() {
1326        let mut inv = Inventory::new();
1327        inv.add(Position::simple(Amount::new(d(10), "STK")))
1328            .expect("fixture fits in Decimal");
1329        inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal");
1330
1331        let result = inv
1332            .reduce(
1333                &Amount::new(d(-5), "STK"),
1334                Some(&CostSpec::default()),
1335                BookingMethod::Hifo,
1336            )
1337            .expect("5 of 20 units are there");
1338
1339        assert_eq!(
1340            result.matched[0].cost.as_ref().map(|c| c.number),
1341            Some(d(100)),
1342            "the 100 USD lot outranks the cost-less one",
1343        );
1344        assert_eq!(
1345            result.cost_basis.map(|b| b.number),
1346            Some(d(500)),
1347            "and the basis comes from it",
1348        );
1349    }
1350
1351    /// The ordered index selects exactly what the scan selects (#2083).
1352    ///
1353    /// `plan_ordered` used to sort every matching lot on every call; it now
1354    /// walks a maintained (date, slot) index and stops once the reduction is
1355    /// covered. That is only sound if the index reproduces the scan's order
1356    /// exactly — including the stable-sort tiebreak, `None` dates sorting
1357    /// first, and lots added out of date order — so this runs both paths over
1358    /// the same inventory and compares.
1359    ///
1360    /// Clearing `ordered_index` is what forces the scan: an empty index is
1361    /// how a shared snapshot looks, and the fallback exists for exactly that.
1362    ///
1363    /// What this does NOT check is whether the ORDER is the right one. Both
1364    /// sides call `order_key`, so reversing it moves them together and this
1365    /// test stays green — verified by mutating it. The orderings themselves
1366    /// are pinned by the method tests (`test_hifo_reduces_highest_cost_first`,
1367    /// `test_fifo_respects_dates` and their neighbors), which is the division
1368    /// of labor: those say what order a method takes lots in, this says the
1369    /// index reproduces whatever that order is.
1370    #[test]
1371    fn the_ordered_index_selects_what_the_scan_selects() {
1372        // Deliberately awkward: out-of-order dates, a duplicate date, a
1373        // date-less lot, a second currency, and a cost-less lot.
1374        let mut inv = Inventory::new();
1375        for lot in [
1376            lot(10, 100, 5),
1377            lot(10, 101, 2),
1378            lot(10, 102, 9),
1379            lot(10, 103, 2),
1380            Position::with_cost(Amount::new(d(10), "STK"), Cost::new(d(104), "USD")),
1381            Position::with_cost(Amount::new(d(10), "OTH"), Cost::new(d(105), "USD")),
1382            Position::simple(Amount::new(d(10), "STK")),
1383        ] {
1384            inv.add(lot).expect("fixture fits in Decimal");
1385        }
1386
1387        let specs = [
1388            CostSpec::default(),
1389            CostSpec {
1390                number: Some(crate::CostNumber::PerUnit { value: d(101) }),
1391                currency: Some("USD".into()),
1392                ..CostSpec::default()
1393            },
1394            CostSpec {
1395                date: Some(naive_date(2024, 1, 2).unwrap()),
1396                ..CostSpec::default()
1397            },
1398        ];
1399
1400        for (order, reverse) in [
1401            (LotOrder::Date, false),
1402            (LotOrder::Date, true),
1403            // HIFO: the cost ordering added in #2091. Its tiebreak has to match
1404            // the `sort_by_key(Reverse(cost))` it replaced — stable, so equal
1405            // costs stayed in ascending slot order.
1406            (LotOrder::CostDescending, false),
1407        ] {
1408            for spec in &specs {
1409                {
1410                    for take in [1i64, 15, 45] {
1411                        let units = Amount::new(d(-take), "STK");
1412
1413                        // Build it explicitly: `reduce` is what normally triggers
1414                        // the build, and calling `plan_ordered` directly would
1415                        // otherwise leave the index empty and compare the scan
1416                        // against itself. That vacuous version of this test passed
1417                        // against a deliberately reversed tiebreak.
1418                        let mut indexing = inv.clone();
1419                        indexing.build_ordered_index(order);
1420                        assert!(
1421                            indexing.ordered_index.is_some(),
1422                            "the fixture must produce an index, or this test compares \
1423                         the scan against itself",
1424                        );
1425                        let indexed = indexing.plan_ordered(&units, spec, order, reverse);
1426
1427                        let mut scanning = inv.clone();
1428                        scanning.ordered_index = None;
1429                        let scanned = scanning.plan_ordered(&units, spec, order, reverse);
1430
1431                        match (indexed, scanned) {
1432                            (Ok((a_result, a_updates)), Ok((b_result, b_updates))) => {
1433                                assert_eq!(
1434                                    a_updates, b_updates,
1435                                    "index and scan chose different lots for {spec:?} \
1436                                 reverse={reverse} take={take}",
1437                                );
1438                                assert_eq!(
1439                                    a_result.cost_basis, b_result.cost_basis,
1440                                    "index and scan disagree on cost basis for {spec:?} \
1441                                 reverse={reverse} take={take}",
1442                                );
1443                            }
1444                            (Err(a), Err(b)) => assert_eq!(
1445                                a.to_string(),
1446                                b.to_string(),
1447                                "index and scan report different errors for {spec:?} \
1448                             reverse={reverse} take={take}",
1449                            ),
1450                            (a, b) => panic!(
1451                                "index and scan disagree on success for {spec:?} \
1452                             order={order:?} reverse={reverse} take={take}: {a:?} vs {b:?}"
1453                            ),
1454                        }
1455                    }
1456                }
1457            }
1458        }
1459    }
1460
1461    fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1462        let mut i = Inventory::new();
1463        for l in lots {
1464            i.add(l).expect("fixture fits in Decimal");
1465        }
1466        i
1467    }
1468
1469    fn sell_stk(n: i64) -> Amount {
1470        Amount::new(d(-n), "STK")
1471    }
1472
1473    /// A cost-basis overflow part-way through a multi-lot reduction must leave
1474    /// the inventory untouched.
1475    ///
1476    /// `reduce_ordered` states the rule itself — "a failed reduction must
1477    /// leave the inventory untouched" — and enforced it for the sufficiency
1478    /// check, which runs up front. The overflow check did NOT get the same
1479    /// treatment: it lived inside the mutation loop, so a reduction that
1480    /// overflowed on the third lot returned `Err` with the first two already
1481    /// drained. The validator reduces against live `LedgerState` inventories,
1482    /// so that partial drain corrupts every later balance assertion on the
1483    /// account.
1484    ///
1485    /// Computing the whole plan before committing any of it makes the rule
1486    /// hold for both checks by construction.
1487    #[test]
1488    fn an_overflowing_multi_lot_reduction_leaves_the_inventory_untouched() {
1489        // Two lots whose combined cost basis cannot be represented: each is
1490        // two thirds of the range, so the first accumulates fine and the sum
1491        // overflows on the second.
1492        let huge = Decimal::MAX / Decimal::from(3) * Decimal::from(2);
1493        let mut inv = Inventory::new();
1494        for day in 1..=2 {
1495            let mut cost = Cost::new(huge, "USD");
1496            cost.date = naive_date(2024, 1, day);
1497            inv.add(Position::with_cost(Amount::new(Decimal::ONE, "AAPL"), cost))
1498                .expect("lots fit individually");
1499        }
1500        let before: Vec<Position> = inv.positions().cloned().collect();
1501        assert_eq!(before.len(), 2, "fixture must hold two distinct lots");
1502
1503        let err = inv
1504            .reduce(
1505                &Amount::new(Decimal::from(-2), "AAPL"),
1506                Some(&CostSpec::default()),
1507                BookingMethod::Fifo,
1508            )
1509            .expect_err("the combined cost basis overflows");
1510        assert!(
1511            matches!(err, super::BookingError::Overflow(_)),
1512            "expected an overflow, got {err:?}",
1513        );
1514
1515        let after: Vec<Position> = inv.positions().cloned().collect();
1516        assert_eq!(
1517            after, before,
1518            "the failed reduction drained lots anyway — a partial mutation on \
1519             the error path is what this pins",
1520        );
1521    }
1522
1523    fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1524        inv.try_reduce(units, Some(&CostSpec::default()), method)
1525            .expect("reduction should succeed")
1526    }
1527
1528    fn basis(r: &super::BookingResult) -> Decimal {
1529        r.cost_basis.as_ref().expect("cost basis present").number
1530    }
1531
1532    // ---- FIFO / LIFO ordered ------------------------------------------
1533
1534    #[test]
1535    fn fifo_partial_multilot_cost_basis_and_order() {
1536        // 10 @ $100 (older), 10 @ $200 (newer); sell 15.
1537        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1538        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1539        // FIFO: 10@100 + 5@200 = 1000 + 1000 = 2000.
1540        assert_eq!(basis(&r), dec!(2000));
1541        assert_eq!(r.matched.len(), 2);
1542        assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1543        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1544        assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1545        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1546    }
1547
1548    #[test]
1549    fn lifo_takes_newest_lot_first() {
1550        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1551        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1552        // LIFO: 10@200 + 5@100 = 2000 + 500 = 2500 (distinguishes the
1553        // `reverse` flag from FIFO's 2000).
1554        assert_eq!(basis(&r), dec!(2500));
1555        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1556    }
1557
1558    #[test]
1559    fn fifo_single_lot_partial_cost_basis() {
1560        let inv = mk([lot(10, 100, 1)]);
1561        let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1562        assert_eq!(basis(&r), dec!(300)); // 3 * 100
1563    }
1564
1565    // ---- HIFO ---------------------------------------------------------
1566
1567    #[test]
1568    fn hifo_takes_highest_cost_lot_first() {
1569        // costs 100, 300, 200 → HIFO order 300, 200, 100.
1570        let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1571        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1572        // 10@300 + 5@200 = 3000 + 1000 = 4000.
1573        assert_eq!(basis(&r), dec!(4000));
1574        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1575        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1576    }
1577
1578    // ---- AVERAGE ------------------------------------------------------
1579
1580    #[test]
1581    fn average_cost_basis_partial() {
1582        // 10 @ $100, 30 @ $200 → 40 units, $7000 total, avg $175.
1583        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1584        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1585        assert_eq!(basis(&r), dec!(3500)); // 20 * 175
1586    }
1587
1588    #[test]
1589    fn average_reduce_exact_total_succeeds() {
1590        // Reducing exactly the held quantity must succeed (kills
1591        // `reduction > total` → `>=`/`==`).
1592        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1593        let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1594        assert_eq!(basis(&r), dec!(7000)); // 40 * 175
1595    }
1596
1597    #[test]
1598    fn average_over_reduction_errors() {
1599        // Reducing more than held must error (kills `>` → `<`).
1600        let inv = mk([lot(10, 100, 1)]);
1601        let err = inv
1602            .try_reduce(
1603                &sell_stk(20),
1604                Some(&CostSpec::default()),
1605                BookingMethod::Average,
1606            )
1607            .unwrap_err();
1608        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1609    }
1610
1611    // ---- Filter isolation (currency / sign) ---------------------------
1612    // One fixture per method: an unrelated OTH lot plus the real STK lot.
1613    // A correct reducer touches ONLY the real STK lot; the currency `==`
1614    // and the `&&` connecting it would pull OTH in (or drop the real
1615    // one), changing the basis. (A zero-units "empty" lot is intentionally
1616    // NOT added here: `Inventory::add` drops empty positions on insert, so
1617    // the `!is_empty()` filter clause is unreachable for add-built
1618    // inventories and can't be exercised this way.)
1619
1620    fn isolation_inv() -> Inventory {
1621        let mut i = Inventory::new();
1622        i.add(Position::with_cost(
1623            Amount::new(dec!(10), "OTH"), // different currency: must be ignored
1624            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1625        ))
1626        .expect("fixture fits in Decimal");
1627        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // the real STK lot
1628        i
1629    }
1630
1631    fn assert_isolated(method: BookingMethod) {
1632        let inv = isolation_inv();
1633        let r = try_reduce(&inv, &sell_stk(5), method);
1634        assert_eq!(
1635            basis(&r),
1636            dec!(500),
1637            "must reduce only the real STK lot (5 * 100)"
1638        );
1639        assert!(
1640            r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1641            "no non-STK lot should be matched"
1642        );
1643    }
1644
1645    #[test]
1646    fn fifo_filters_currency() {
1647        assert_isolated(BookingMethod::Fifo);
1648    }
1649
1650    #[test]
1651    fn hifo_filters_currency() {
1652        assert_isolated(BookingMethod::Hifo);
1653    }
1654
1655    #[test]
1656    fn strict_filters_currency() {
1657        assert_isolated(BookingMethod::Strict);
1658    }
1659
1660    #[test]
1661    fn average_filters_currency() {
1662        // average filters by currency + non-empty (no cost-spec / sign filter).
1663        let inv = isolation_inv();
1664        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1665        // Only the STK lot participates: 10 units @ $100 → avg $100 → 5 * 100.
1666        assert_eq!(basis(&r), dec!(500));
1667    }
1668
1669    // ---- Sign guard ---------------------------------------------------
1670
1671    #[test]
1672    fn does_not_match_same_sign_lot() {
1673        // A short (negative) STK lot must NOT satisfy a sell (negative
1674        // units): same sign. Only the long lot is reducible. Kills the
1675        // `signum() != signum()` → `==` mutant (== would match the short
1676        // lot or nothing).
1677        let mut i = Inventory::new();
1678        i.add(lot(-10, 50, 1)).expect("fixture fits in Decimal"); // short lot, same sign as a sell
1679        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // long lot
1680        let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1681        assert_eq!(basis(&r), dec!(500)); // 5 * 100 from the long lot only
1682        assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1683    }
1684
1685    #[test]
1686    fn strict_rejects_when_only_same_sign_lot_present() {
1687        // STRICT against an inventory holding ONLY a same-sign (short)
1688        // lot must return NoMatchingLot — the single reducible lot fails
1689        // `can_reduce`, leaving zero matches. This pins all three `&&`
1690        // connectors in `try_reduce_strict`'s filter: each `&& -> ||`
1691        // mutant wrongly admits the short lot (currency==STK or the
1692        // always-true `matches_cost_spec` on the default spec satisfies
1693        // the disjunction), turning 0 matches into 1 and succeeding via
1694        // `try_reduce_from_lot` instead of erroring.
1695        let mut i = Inventory::new();
1696        i.add(lot(-10, 100, 1)).expect("fixture fits in Decimal"); // short STK only; a sell is the same sign
1697        let res = i.try_reduce(
1698            &sell_stk(5),
1699            Some(&CostSpec::default()),
1700            BookingMethod::Strict,
1701        );
1702        assert!(
1703            matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1704            "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1705        );
1706    }
1707
1708    // ---- Insufficient-units accounting --------------------------------
1709
1710    #[test]
1711    fn fifo_insufficient_reports_available() {
1712        // `available = requested - remaining`; kills the `-` → `+`/`/`
1713        // mutant in the insufficient branch.
1714        let inv = mk([lot(10, 100, 1)]);
1715        let err = inv
1716            .try_reduce(
1717                &sell_stk(15),
1718                Some(&CostSpec::default()),
1719                BookingMethod::Fifo,
1720            )
1721            .unwrap_err();
1722        match err {
1723            super::BookingError::InsufficientUnits {
1724                requested,
1725                available,
1726                ..
1727            } => {
1728                assert_eq!(requested, dec!(15));
1729                assert_eq!(available, dec!(10)); // 15 requested - 5 remaining
1730            }
1731            other => panic!("expected InsufficientUnits, got {other:?}"),
1732        }
1733    }
1734
1735    // ---- STRICT single-lot path (try_reduce_from_lot) -----------------
1736
1737    #[test]
1738    fn strict_single_lot_partial_cost_basis() {
1739        // Exactly one matching lot → try_reduce_from_lot; partial take.
1740        let inv = mk([lot(10, 100, 1)]);
1741        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1742        assert_eq!(basis(&r), dec!(400)); // 4 * 100
1743    }
1744
1745    #[test]
1746    fn strict_single_lot_over_reduction_errors() {
1747        // from_lot `requested > available` guard.
1748        let inv = mk([lot(10, 100, 1)]);
1749        let err = inv
1750            .try_reduce(
1751                &sell_stk(11),
1752                Some(&CostSpec::default()),
1753                BookingMethod::Strict,
1754            )
1755            .unwrap_err();
1756        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1757    }
1758
1759    #[test]
1760    fn strict_single_lot_exact_full_reduction_succeeds() {
1761        // requested == available must succeed (kills from_lot `>` → `>=`).
1762        let inv = mk([lot(10, 100, 1)]);
1763        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1764        assert_eq!(basis(&r), dec!(1000));
1765    }
1766
1767    // ---- HIFO matched units + insufficient accounting ----------------
1768
1769    #[test]
1770    fn hifo_matched_units_and_insufficient_available() {
1771        let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1772        let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1773        // 8 taken from the $300 lot (kills the split `take * signum -> +`).
1774        assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1775        let err = inv
1776            .try_reduce(
1777                &sell_stk(25),
1778                Some(&CostSpec::default()),
1779                BookingMethod::Hifo,
1780            )
1781            .unwrap_err();
1782        match err {
1783            super::BookingError::InsufficientUnits { available, .. } => {
1784                assert_eq!(available, dec!(20)); // 20 held; kills `abs - remaining` mutants
1785            }
1786            other => panic!("expected InsufficientUnits, got {other:?}"),
1787        }
1788    }
1789
1790    #[test]
1791    fn strict_from_lot_matched_units() {
1792        let inv = mk([lot(10, 100, 1)]);
1793        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1794        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills from_lot split `* -> +`
1795    }
1796
1797    // ---- StrictWithSize ----------------------------------------------
1798
1799    #[test]
1800    fn strict_with_size_picks_exact_size_lot() {
1801        let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1802        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1803        assert_eq!(basis(&r), dec!(1000)); // 5 @ $200, the exact-size lot
1804    }
1805
1806    #[test]
1807    fn strict_with_size_takes_the_oldest_of_several_exact_size_lots() {
1808        // #2097. Two lots of the reduction's size, so size alone does not
1809        // disambiguate. Beancount sorts the size matches by `cost.date` and
1810        // takes the first; the choice decides both the basis realized and the
1811        // holding period of what survives.
1812        //
1813        // The lots are built in the OPPOSITE order to their dates, which is
1814        // what the old `find`-first-in-slot-order got wrong. Slot order is
1815        // insertion order and usually matches date order by accident — but a
1816        // lot carrying an explicit cost date is inserted when its transaction
1817        // books and dated whenever the user wrote. Verified against beancount
1818        // 3.2.3, which leaves the 100-cost lot standing.
1819        let inv = mk([lot(10, 100, 20), lot(10, 200, 5)]);
1820        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::StrictWithSize);
1821        assert_eq!(
1822            basis(&r),
1823            dec!(2000),
1824            "must realize the OLDEST size match (day 5, cost 200), not the \
1825             first one stored (day 20, cost 100)"
1826        );
1827    }
1828
1829    #[test]
1830    fn strict_with_size_ambiguous_without_exact_or_total() {
1831        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1832        let err = inv
1833            .try_reduce(
1834                &sell_stk(5),
1835                Some(&CostSpec::default()),
1836                BookingMethod::StrictWithSize,
1837            )
1838            .unwrap_err();
1839        assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1840    }
1841
1842    #[test]
1843    fn strict_with_size_total_match_falls_back_to_fifo() {
1844        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1845        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1846        assert_eq!(basis(&r), dec!(3000)); // total match → FIFO: 1000 + 2000
1847
1848        // The basis alone cannot tell FIFO from LIFO here: a total match
1849        // consumes every matching lot, so any order sums to 3000. The name of
1850        // this test is about the ORDER, so assert it — flipping the fallback
1851        // to LIFO used to leave this green.
1852        assert_eq!(
1853            r.matched
1854                .iter()
1855                .map(|p| p.cost.as_ref().map(|c| c.number))
1856                .collect::<Vec<_>>(),
1857            vec![Some(dec!(100)), Some(dec!(200))],
1858            "oldest lot first",
1859        );
1860    }
1861
1862    // ---- Mutating reduce() path (reduce_*) ----------------------------
1863
1864    #[test]
1865    fn reduce_fifo_commits_and_basis() {
1866        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1867        let r = inv
1868            .reduce(
1869                &sell_stk(15),
1870                Some(&CostSpec::default()),
1871                BookingMethod::Fifo,
1872            )
1873            .unwrap();
1874        assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1875        assert_eq!(inv.units("STK"), dec!(5)); // 20 - 15
1876    }
1877
1878    #[test]
1879    fn reduce_on_large_shared_inventory_does_not_corrupt() {
1880        // Regression: the rich-workload profiler found a heap-corruption /
1881        // SIGSEGV when reducing an inventory that had been cloned (imbl O(1)
1882        // structural share, as the booking engine does for working copies).
1883        // In-place mutation of the SHARED imbl `Vector` double-freed the interned
1884        // `Arc<str>` inside `Position`. Needs >64 distinct lots so the `Vector`
1885        // spans multiple Arc-backed chunks — the representation that actually
1886        // shares (and corrupted). Without the fix this aborts/segfaults on drop.
1887        // 100 distinct-cost lots (>64 = the imbl chunk size) so the `Vector`
1888        // spans multiple Arc-backed chunks — the shared representation that
1889        // corrupted. Day stays a valid 1..=28 (lots remain distinct by cost).
1890        // The Miri CI job (`rustledger-core`, strict provenance) executes this
1891        // and flags the use-after-free deterministically when the guard is gone.
1892        let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
1893        let snapshot = inv.clone(); // structurally shares chunks with `inv`
1894        inv.reduce(
1895            &sell_stk(700),
1896            Some(&CostSpec::default()),
1897            BookingMethod::Fifo,
1898        )
1899        .unwrap();
1900        assert_eq!(inv.units("STK"), dec!(300)); // 1000 - 700
1901        // The shared snapshot stays independent and intact; `units` re-reads
1902        // every interned currency, and dropping both must not double-free.
1903        assert_eq!(snapshot.units("STK"), dec!(1000));
1904    }
1905
1906    #[test]
1907    fn reduce_hifo_commits_basis_units_insufficient() {
1908        let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1909        let r = inv
1910            .reduce(
1911                &sell_stk(15),
1912                Some(&CostSpec::default()),
1913                BookingMethod::Hifo,
1914            )
1915            .unwrap();
1916        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 10@300 + 5@100
1917        assert_eq!(r.matched[0].units.number.abs(), dec!(10)); // kills reduce_hifo split `* -> +`
1918        let mut inv2 = mk([lot(10, 100, 1)]);
1919        let err = inv2
1920            .reduce(
1921                &sell_stk(25),
1922                Some(&CostSpec::default()),
1923                BookingMethod::Hifo,
1924            )
1925            .unwrap_err();
1926        match err {
1927            super::BookingError::InsufficientUnits { available, .. } => {
1928                assert_eq!(available, dec!(10));
1929            }
1930            other => panic!("expected InsufficientUnits, got {other:?}"),
1931        }
1932    }
1933
1934    #[test]
1935    fn reduce_average_only_matching_currency() {
1936        let mut i = Inventory::new();
1937        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal");
1938        i.add(Position::with_cost(
1939            Amount::new(dec!(10), "OTH"),
1940            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1941        ))
1942        .expect("fixture fits in Decimal");
1943        let r = i
1944            .reduce(
1945                &sell_stk(5),
1946                Some(&CostSpec::default()),
1947                BookingMethod::Average,
1948            )
1949            .unwrap();
1950        assert_eq!(r.cost_basis.unwrap().number, dec!(500)); // only the STK lot
1951    }
1952
1953    #[test]
1954    fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
1955        // Regression: a partial AVERAGE sale across multiple lots matches a
1956        // SINGLE synthetic lot of the reduced quantity at the average cost, not
1957        // every underlying lot. Returning the full lot set made the consumer
1958        // (book.rs) expand the reduction into one posting per lot, emptying the
1959        // position and booking a garbage gain.
1960        let mut i = Inventory::new();
1961        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
1962        i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
1963        let r = i
1964            .reduce(
1965                &sell_stk(5),
1966                Some(&CostSpec::default()),
1967                BookingMethod::Average,
1968            )
1969            .unwrap();
1970
1971        // One synthetic matched lot at the average cost {160}; basis 5*160=800.
1972        // Long pool: the matched lot carries the inventory (positive) sign.
1973        assert_eq!(r.matched.len(), 1);
1974        assert_eq!(r.cost_basis.as_ref().unwrap().number, dec!(800));
1975        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(160));
1976        assert_eq!(r.matched[0].units.number, dec!(5));
1977
1978        // 15 STK remain as a single lot carrying the average cost {160}.
1979        assert_eq!(i.units("STK"), dec!(15));
1980        let remaining: Vec<&Position> = i
1981            .positions()
1982            .filter(|p| p.units.currency == "STK")
1983            .collect();
1984        assert_eq!(remaining.len(), 1);
1985        assert_eq!(remaining[0].cost.as_ref().unwrap().number, dec!(160));
1986    }
1987
1988    #[test]
1989    fn reduce_average_short_cover_matched_lot_carries_inventory_sign() {
1990        // Covering a short (positive units reducing a negative pool) must return
1991        // a matched lot with the inventory (negative) sign, like FIFO/ordered.
1992        let mut i = Inventory::new();
1993        i.add(Position::with_cost(
1994            Amount::new(dec!(-10), "STK"),
1995            Cost::new(dec!(150), "USD"),
1996        ))
1997        .expect("fixture fits in Decimal");
1998        let r = i
1999            .reduce(
2000                &Amount::new(dec!(5), "STK"),
2001                Some(&CostSpec::default()),
2002                BookingMethod::Average,
2003            )
2004            .unwrap();
2005        assert_eq!(r.matched.len(), 1);
2006        assert_eq!(r.matched[0].units.number, dec!(-5));
2007        // Short pool shrinks from -10 to -5.
2008        assert_eq!(i.units("STK"), dec!(-5));
2009    }
2010
2011    #[test]
2012    fn merge_average_collapses_lots_to_single_weighted_lot() {
2013        // The realized balance of an AVERAGE account is one pool at the
2014        // weighted-average cost: (10*150 + 10*170 - 5*160) / 15 = 160.
2015        let mut i = Inventory::new();
2016        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2017        i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
2018        i.add(Position::with_cost(
2019            Amount::new(dec!(-5), "STK"),
2020            Cost::new(dec!(160), "USD"),
2021        ))
2022        .expect("fixture fits in Decimal");
2023        i.merge_average().expect("fixture fits in Decimal");
2024        let stk: Vec<&Position> = i
2025            .positions()
2026            .filter(|p| p.units.currency == "STK")
2027            .collect();
2028        assert_eq!(stk.len(), 1);
2029        assert_eq!(stk[0].units.number, dec!(15));
2030        assert_eq!(stk[0].cost.as_ref().unwrap().number, dec!(160));
2031    }
2032
2033    #[test]
2034    fn merge_average_net_zero_removes_lots() {
2035        let mut i = Inventory::new();
2036        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2037        i.add(Position::with_cost(
2038            Amount::new(dec!(-10), "STK"),
2039            Cost::new(dec!(160), "USD"),
2040        ))
2041        .expect("fixture fits in Decimal");
2042        i.merge_average().expect("fixture fits in Decimal");
2043        assert_eq!(
2044            i.positions().filter(|p| p.units.currency == "STK").count(),
2045            0
2046        );
2047    }
2048
2049    #[test]
2050    fn merge_average_leaves_costless_positions_untouched() {
2051        let mut i = Inventory::new();
2052        i.add(Position::simple(Amount::new(dec!(100), "USD")))
2053            .expect("fixture fits in Decimal");
2054        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2055        i.merge_average().expect("fixture fits in Decimal");
2056        // Cash stays; the single STK lot stays a single lot.
2057        assert_eq!(i.units("USD"), dec!(100));
2058        assert_eq!(
2059            i.positions().filter(|p| p.units.currency == "STK").count(),
2060            1
2061        );
2062    }
2063
2064    #[test]
2065    fn reduce_from_lot_matched_and_remaining_units() {
2066        let mut inv = mk([lot(10, 100, 1)]);
2067        let r = inv
2068            .reduce(
2069                &sell_stk(4),
2070                Some(&CostSpec::default()),
2071                BookingMethod::Strict,
2072            )
2073            .unwrap();
2074        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills reduce_from_lot split `* -> +`
2075        // Assert the stored POSITION units directly, not `units()` — the
2076        // latter reads a separate incremental cache, so it would not catch
2077        // a bug in `new_units = pos.units.number + units.number`.
2078        let remaining: Vec<_> = inv.position_list();
2079        assert_eq!(remaining.len(), 1);
2080        assert_eq!(remaining[0].units.number, dec!(6)); // 10 + (-4); kills `+ -> -`/`*`
2081        assert_eq!(inv.units("STK"), dec!(6)); // cache stays consistent
2082    }
2083
2084    #[test]
2085    fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
2086        // Merge two long STK lots; a short STK lot (same sign as the
2087        // sell) and an unrelated OTH lot must be excluded from the merge
2088        // AND survive in the inventory.
2089        let mut inv = Inventory::new();
2090        inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal"); // long STK
2091        inv.add(lot(30, 200, 2)).expect("fixture fits in Decimal"); // long STK
2092        inv.add(lot(-5, 999, 3)).expect("fixture fits in Decimal"); // short STK — excluded by the sign filter
2093        inv.add(Position::with_cost(
2094            Amount::new(dec!(10), "OTH"), // different currency — excluded
2095            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
2096        ))
2097        .expect("fixture fits in Decimal");
2098        let spec = CostSpec {
2099            merge: true,
2100            ..CostSpec::default()
2101        };
2102        let r = inv
2103            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2104            .unwrap();
2105        // Only the two long STK lots merge: 40 units @ avg $175 → 20 * 175.
2106        // Including the short (sign) or OTH (currency) lot would change this.
2107        assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
2108        // The excluded lots must still be present (kills the retain-index mutant).
2109        assert!(
2110            inv.position_list()
2111                .iter()
2112                .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
2113            "OTH lot must survive the merge"
2114        );
2115        assert!(
2116            inv.position_list()
2117                .iter()
2118                .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
2119            "short STK lot must survive the merge"
2120        );
2121    }
2122
2123    #[test]
2124    fn reduce_none_exact_succeeds_over_reduction_shorts() {
2125        let mut inv = Inventory::new();
2126        inv.add(Position::simple(Amount::new(dec!(10), "STK")))
2127            .expect("fixture fits in Decimal");
2128        assert!(
2129            inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
2130            "exact NONE reduction should succeed"
2131        );
2132        // NONE performs no booking, so over-reduction shorts past zero
2133        // instead of erroring (#1686 — previously InsufficientUnits, which
2134        // made the outcome depend on whether zero was crossed in one step).
2135        let mut inv2 = Inventory::new();
2136        inv2.add(Position::simple(Amount::new(dec!(10), "STK")))
2137            .expect("fixture fits in Decimal");
2138        assert!(
2139            inv2.reduce(&sell_stk(15), None, BookingMethod::None)
2140                .is_ok(),
2141            "NONE over-reduction must short, not error (#1686)"
2142        );
2143        assert_eq!(inv2.units("STK"), dec!(-5));
2144    }
2145
2146    #[test]
2147    fn reduce_merge_uses_weighted_average() {
2148        let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
2149        let spec = CostSpec {
2150            merge: true,
2151            ..CostSpec::default()
2152        };
2153        let r = inv
2154            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2155            .unwrap();
2156        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 20 @ avg $175
2157        assert_eq!(inv.units("STK"), dec!(20)); // 40 - 20
2158    }
2159}