Skip to main content

rustledger_core/inventory/
mod.rs

1//! Inventory type representing a collection of positions.
2//!
3//! An [`Inventory`] tracks the holdings of an account as a collection of
4//! [`Position`]s. It provides methods for adding and reducing positions
5//! using different booking methods (FIFO, LIFO, STRICT, NONE).
6
7// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
8use imbl::Vector;
9use rust_decimal::Decimal;
10use rustc_hash::FxHashMap;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13use std::fmt;
14use std::str::FromStr;
15
16use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
17
18/// Inline storage for `BookingResult::matched`.
19///
20/// STRICT booking (the default) always produces exactly one matched lot
21/// per posting; FIFO / LIFO frequently match a single lot too. Inline
22/// cap of 1 covers the hot case with zero heap allocation while still
23/// spilling to the heap for multi-lot matches.
24///
25/// **API surface note**: this is `pub(crate)` deliberately — we don't
26/// want to commit downstream consumers to `smallvec` as part of our
27/// public API contract. External code reads `BookingResult.matched` via
28/// the slice deref (`.iter()`, `.len()`, indexing) which works
29/// transparently. The concrete `SmallVec<[Position; 1]>` type is still
30/// reachable via the field type but isn't promoted into the crate root.
31pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
32
33mod booking;
34
35/// Booking method determines how lots are matched when reducing positions.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
37#[cfg_attr(
38    feature = "rkyv",
39    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
40)]
41pub enum BookingMethod {
42    /// Lots must match exactly (unambiguous).
43    /// If multiple lots match the cost spec, an error is raised.
44    #[default]
45    Strict,
46    /// Like STRICT, but exact-size matches accept oldest lot.
47    /// If reduction amount equals total inventory, it's considered unambiguous.
48    StrictWithSize,
49    /// First In, First Out. Oldest lots are reduced first.
50    Fifo,
51    /// Last In, First Out. Newest lots are reduced first.
52    Lifo,
53    /// Highest In, First Out. Highest-cost lots are reduced first.
54    Hifo,
55    /// Average cost booking. All lots of a currency are merged.
56    Average,
57    /// No cost tracking. Units are reduced without matching lots.
58    None,
59}
60
61impl FromStr for BookingMethod {
62    type Err = String;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        match s.to_uppercase().as_str() {
66            "STRICT" => Ok(Self::Strict),
67            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
68            "FIFO" => Ok(Self::Fifo),
69            "LIFO" => Ok(Self::Lifo),
70            "HIFO" => Ok(Self::Hifo),
71            "AVERAGE" => Ok(Self::Average),
72            "NONE" => Ok(Self::None),
73            _ => Err(format!("unknown booking method: {s}")),
74        }
75    }
76}
77
78impl fmt::Display for BookingMethod {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Strict => write!(f, "STRICT"),
82            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
83            Self::Fifo => write!(f, "FIFO"),
84            Self::Lifo => write!(f, "LIFO"),
85            Self::Hifo => write!(f, "HIFO"),
86            Self::Average => write!(f, "AVERAGE"),
87            Self::None => write!(f, "NONE"),
88        }
89    }
90}
91
92/// Controls which positions are considered when checking whether incoming
93/// units reduce (i.e. have the opposite sign of) an existing inventory.
94///
95/// - [`AllPositions`](ReductionScope::AllPositions): every position is
96///   considered, regardless of whether it carries a cost.
97/// - [`CostBearingOnly`](ReductionScope::CostBearingOnly): only positions
98///   with a cost are considered.  This prevents a negative simple (no-cost)
99///   position — left behind by a sell-without-cost-spec — from causing a
100///   subsequent cost-bearing augmentation to be misclassified as a reduction.
101///   See: issue #875, beancount#889.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum ReductionScope {
104    /// Consider all positions (cost-bearing and simple).
105    AllPositions,
106    /// Consider only positions that carry a cost.
107    CostBearingOnly,
108}
109
110/// Result of a booking operation.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BookingResult {
113    /// Positions that were matched/reduced.
114    ///
115    /// Backed by [`SmallVec<[Position; 1]>`](smallvec::SmallVec) so the
116    /// single-match common case (always true under STRICT, common under
117    /// FIFO/LIFO) doesn't touch the heap. The concrete type derefs to
118    /// `[Position]`, so read-side patterns like `.iter()`,
119    /// `.len()`, `.is_empty()`, and indexing work unchanged.
120    ///
121    /// **Breaking API change in 0.15.0**: prior versions used
122    /// `Vec<Position>`. Downstream code that named the type explicitly
123    /// (`let v: Vec<Position> = result.matched`) or called Vec-specific
124    /// methods (`.capacity()`, `.reserve()`) needs to adapt; reading
125    /// the field through the slice deref keeps working.
126    pub matched: MatchedLots,
127    /// The cost basis of the matched positions (for capital gains).
128    pub cost_basis: Option<Amount>,
129}
130
131/// Error that can occur during booking.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum BookingError {
134    /// Multiple lots match but booking method requires unambiguous match.
135    AmbiguousMatch {
136        /// Number of lots that matched.
137        num_matches: usize,
138        /// The currency being reduced.
139        currency: crate::Currency,
140    },
141    /// No lots match the cost specification.
142    NoMatchingLot {
143        /// The currency being reduced.
144        currency: crate::Currency,
145        /// The cost spec that didn't match.
146        cost_spec: CostSpec,
147    },
148    /// Not enough units in matching lots.
149    InsufficientUnits {
150        /// The currency being reduced.
151        currency: crate::Currency,
152        /// Units requested.
153        requested: Decimal,
154        /// Units available.
155        available: Decimal,
156    },
157    /// Currency mismatch between reduction and inventory.
158    CurrencyMismatch {
159        /// Expected currency.
160        expected: crate::Currency,
161        /// Got currency.
162        got: crate::Currency,
163    },
164    /// A `{*}` merge produced a different pool than booking recorded (#2068).
165    ///
166    /// `{*}` is an OPERATION, not a filter: unlike every other cost spec it
167    /// restructures the lots before selecting from them. Booking therefore
168    /// carries the marker into application rather than resolving it into a
169    /// per-unit cost, because the lot it would name does not exist until the
170    /// merge runs.
171    ///
172    /// The consequence is that a booked posting carrying `{*}` re-executes the
173    /// merge when applied, so it is only meaningful against the state it was
174    /// booked against. Booking also records the pool cost it computed, and
175    /// application checks it here — turning "applied against different state,
176    /// silently different answer" into a reported error.
177    MergeMismatch {
178        /// The commodity being reduced (e.g. `AAPL`).
179        currency: crate::Currency,
180        /// The per-unit pool cost booking recorded, in the cost currency.
181        expected: crate::Amount,
182        /// The per-unit pool cost the merge would produce, in the cost currency.
183        ///
184        /// Carried as an [`Amount`] rather than a bare number
185        /// so the message reads `110.00 USD`: the pool cost is denominated in
186        /// the COST currency, which is not the commodity in `currency`.
187        got: crate::Amount,
188    },
189    /// The arithmetic left `rust_decimal`'s ~±7.9e28 range (#1863).
190    ///
191    /// Reported rather than clamped: `Decimal::MIN == -Decimal::MAX`, so
192    /// clamped debits and credits cancel to a residual of exactly zero and an
193    /// arbitrarily unbalanced ledger certifies as clean. Reported rather than
194    /// panicked because ledger input must never abort the CLI.
195    Overflow(OverflowError),
196}
197
198/// A `Decimal` computation whose result cannot be represented.
199///
200/// `rust_decimal` is a 96-bit type with a hard ~±7.9e28 magnitude ceiling and
201/// its `+`/`*` panic on overflow. There is no in-range answer to substitute,
202/// so the arithmetic reports instead of clamping.
203///
204/// Used where an operation MUTATES an inventory, so a caller that reports and
205/// continues needs to know which currency was left alone. Pure leaf arithmetic
206/// returns a plain `Option` instead and lets its caller supply the context:
207/// [`crate::Cost::total_cost`], [`sum_account_and_subaccounts`], and
208/// `rustledger_booking`'s weight ladder. The split is about who is positioned
209/// to write the diagnostic, not about which failures matter.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct OverflowError {
212    /// The currency whose running total left the range.
213    pub currency: crate::Currency,
214}
215
216impl fmt::Display for OverflowError {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        write!(
219            f,
220            "{} amount exceeds the representable range (±7.9e28); \
221             split the transaction, or denominate it in larger units \
222             (thousands, millions) so the number is smaller",
223            self.currency
224        )
225    }
226}
227
228impl std::error::Error for OverflowError {}
229
230impl From<OverflowError> for BookingError {
231    fn from(e: OverflowError) -> Self {
232        Self::Overflow(e)
233    }
234}
235
236impl fmt::Display for BookingError {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            Self::MergeMismatch {
240                currency,
241                expected,
242                got,
243            } => write!(
244                f,
245                "{{*}} merge of {currency} would produce a pool cost of {got}, \
246                 but booking recorded {expected}: this posting is being applied \
247                 against different inventory than it was booked against"
248            ),
249            Self::AmbiguousMatch {
250                num_matches,
251                currency,
252            } => write!(
253                f,
254                "Ambiguous match: {num_matches} lots match for {currency}"
255            ),
256            Self::NoMatchingLot {
257                currency,
258                cost_spec,
259            } => {
260                write!(f, "No matching lot for {currency} with cost {cost_spec}")
261            }
262            Self::InsufficientUnits {
263                currency,
264                requested,
265                available,
266            } => write!(
267                f,
268                "Insufficient units of {currency}: requested {requested}, available {available}"
269            ),
270            Self::CurrencyMismatch { expected, got } => {
271                write!(f, "Currency mismatch: expected {expected}, got {got}")
272            }
273            Self::Overflow(e) => write!(f, "{e}"),
274        }
275    }
276}
277
278impl std::error::Error for BookingError {}
279
280impl BookingError {
281    /// Wrap this booking error with the account context that produced it.
282    ///
283    /// `Inventory` itself doesn't know which account it belongs to, so the
284    /// raw `BookingError` carries no `account` field. The caller (booking
285    /// engine, validator) knows the account and uses this constructor to
286    /// produce the user-facing error.
287    ///
288    /// The resulting [`AccountedBookingError`] is the **single canonical
289    /// rendering** of an inventory failure for user-facing output. Both the
290    /// booking layer and the validator format errors via this type so the
291    /// wording cannot drift between them — the failure mode that produced
292    /// #748.
293    #[must_use]
294    pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
295        AccountedBookingError {
296            error: self,
297            account,
298        }
299    }
300}
301
302/// A [`BookingError`] paired with the account that produced it.
303///
304/// This is the canonical user-facing inventory error type. Its `Display`
305/// impl is the **single source of truth** for booking-error wording across
306/// `rustledger-booking` and `rustledger-validate`. Conformance assertions
307/// (e.g. pta-standards `reduction-exceeds-inventory` requires the literal
308/// substring `"not enough"`) are pinned by this Display.
309///
310/// Construct via [`BookingError::with_account`].
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct AccountedBookingError {
313    /// The underlying inventory-level error.
314    pub error: BookingError,
315    /// The account whose inventory produced the error.
316    pub account: crate::Account,
317}
318
319impl fmt::Display for AccountedBookingError {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        match &self.error {
322            // The currency is already named in the inner message; the account
323            // is the context this wrapper exists to add.
324            BookingError::Overflow(e) => write!(f, "{}: {e}", self.account),
325            BookingError::MergeMismatch { .. } => write!(f, "{}: {}", self.account, self.error),
326            BookingError::InsufficientUnits {
327                requested,
328                available,
329                ..
330            } => write!(
331                f,
332                "Not enough units in {}: requested {}, available {}; not enough to reduce",
333                self.account, requested, available
334            ),
335            BookingError::NoMatchingLot { currency, .. } => {
336                write!(f, "No matching lot for {} in {}", currency, self.account)
337            }
338            BookingError::AmbiguousMatch {
339                num_matches,
340                currency,
341            } => write!(
342                f,
343                "Ambiguous lot match for {}: {} lots match in {}",
344                currency, num_matches, self.account
345            ),
346            // Currency mismatch is semantically a specialization of
347            // NoMatchingLot (there is no lot for the given currency in this
348            // inventory), so we render and classify it the same way. Consumers
349            // filtering on E4001 don't need to special-case CurrencyMismatch.
350            //
351            // This variant is defensive: no `Inventory::reduce` path in
352            // `rustledger-core` currently emits it, but we still render it
353            // consistently in case a future emission site is added.
354            BookingError::CurrencyMismatch { got, .. } => {
355                write!(f, "No matching lot for {} in {}", got, self.account)
356            }
357        }
358    }
359}
360
361impl std::error::Error for AccountedBookingError {}
362/// How an [`Inventory`] holds its positions.
363///
364/// The two backings exist because the two uses want opposite things, and a
365/// single choice was measurably wrong for one of them:
366///
367/// * **Booking** mutates an inventory constantly — `add`, and a `reduce` that
368///   filters, sorts and then indexes matched lots — and snapshots it only on
369///   the conditional overflow-rollback path. It wants contiguous storage:
370///   O(1) indexing and cache-friendly iteration.
371/// * **BQL's JOURNAL running balance** is only appended to, and is CLONED
372///   once per output row. It wants structural sharing: N snapshots costing
373///   O(base + sum of deltas) rather than O(N x base) (#1086 — measured at
374///   32.7 MB peak RSS for 2000 lots x 2000 rows; a contiguous clone per row
375///   holds ~2M positions instead).
376///
377/// Holding everything in the persistent vector made every reduction pay RRB
378/// costs: on a lot-heavy workload `imbl::Vector`'s iterator alone was ~13% of
379/// all instructions, and indexed access inside `reduce_ordered` is O(log M)
380/// per lookup rather than O(1). Holding everything contiguously reintroduces
381/// the #1086 blow-up. So the representation follows the use.
382#[derive(Debug, Clone)]
383enum PositionStore {
384    /// Contiguous — booking's working representation.
385    ///
386    /// SPARSE: a slot holding `None` is a lot a reduction drained and removed.
387    /// Removing by shifting renumbers every later lot, and lot indices have to
388    /// survive removals for a cost-keyed match index to be possible at all.
389    /// Tombstoning makes removal O(1) and leaves every other slot untouched.
390    ///
391    /// `None` is NOT the same as a zero-unit position. A zero-unit lot is live
392    /// and visible — cost-less lots can merge through zero — and
393    /// [`Inventory::len`] is documented as counting them. A tombstone is a lot
394    /// that is GONE. Encoding one as the other would make drained lots visible
395    /// to `Serialize`, the FFI and wasm converters, the account validator and
396    /// `report balances`, and would change what `currency_accounts` sees when
397    /// it branches on `inv.len() == 1` to match Python.
398    Owned(Slots),
399    /// Structurally shared — BQL's snapshot representation, and dense: BQL
400    /// clones snapshots but never books against them, so it has no removals to
401    /// keep indices stable across.
402    Shared(Vector<Position>),
403}
404
405/// Iterator over [`PositionStore`], as a stack-allocated enum.
406///
407/// Deliberately NOT `Box<dyn Iterator>`: `iter` is called from `units`,
408/// `merge`, `at_cost`, equality and every reduction pass, so boxing would put
409/// a heap allocation and a dynamic dispatch on paths this change exists to
410/// make cheaper. Copilot's catch on #2056.
411enum PositionStoreIter<'a> {
412    Owned(std::iter::Flatten<std::slice::Iter<'a, Option<Position>>>),
413    Shared(imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>),
414}
415
416impl<'a> Iterator for PositionStoreIter<'a> {
417    type Item = &'a Position;
418
419    fn next(&mut self) -> Option<Self::Item> {
420        match self {
421            Self::Owned(i) => i.next(),
422            Self::Shared(i) => i.next(),
423        }
424    }
425
426    fn size_hint(&self) -> (usize, Option<usize>) {
427        match self {
428            Self::Owned(i) => i.size_hint(),
429            Self::Shared(i) => i.size_hint(),
430        }
431    }
432}
433
434/// The sparse backing: slots plus the number that are live.
435///
436/// `live` is maintained rather than counted because `len`, `is_empty` and the
437/// compaction trigger all run per reduction, and an O(slots) scan there is
438/// exactly the cost tombstones exist to avoid.
439#[derive(Debug, Clone, Default)]
440struct Slots {
441    entries: Vec<Option<Position>>,
442    live: usize,
443    /// Prior contents of every slot this transaction has touched, so a failed
444    /// transaction can be undone without having copied the whole account.
445    ///
446    /// `None` while not recording. Recorded lazily and ONCE per slot — the
447    /// first write captures what to restore; later writes to the same slot are
448    /// already covered.
449    ///
450    /// Recording lives here, in the backing, rather than at the eleven call
451    /// sites that mutate positions. Those all funnel through this type's
452    /// primitives and the field is private, so covering the primitives is
453    /// complete by construction; covering call sites would be complete only
454    /// until someone adds a twelfth.
455    undo: Option<Vec<(usize, Option<Position>)>>,
456    /// Slots already captured in `undo`, for O(1) "have I recorded this?".
457    ///
458    /// This bounds the log's size and cost; it is not what makes rollback
459    /// correct. Restoring in reverse order already makes a duplicate entry
460    /// harmless, because the earliest capture is applied last. Removing this
461    /// therefore does not fail any test — it just lets the log grow.
462    ///
463    /// A linear scan of the log reads fine for a transaction touching one or
464    /// two slots, but `{*}` merge records every matched lot, which made
465    /// recording O(k^2) in the lots merged — a fresh quadratic inside the
466    /// change that removed one. Hashing a `usize` with `FxHash` is a couple of
467    /// instructions, so the common case loses nothing.
468    undo_seen: rustc_hash::FxHashSet<usize>,
469}
470
471impl Slots {
472    fn from_live(positions: Vec<Position>) -> Self {
473        let live = positions.len();
474        Self {
475            entries: positions.into_iter().map(Some).collect(),
476            live,
477            undo: None,
478            undo_seen: rustc_hash::FxHashSet::default(),
479        }
480    }
481
482    /// Capture slot `i`'s current contents, if recording and not already held.
483    ///
484    /// The "already held" check is a linear scan. A transaction touches one or
485    /// two slots, where a scan beats hashing; the worst case is a `{*}` merge,
486    /// which records every matched lot and makes this O(k^2) in the lots
487    /// merged. That is bounded by one transaction and by an operation that is
488    /// rare, which is why it is not a set.
489    fn record(&mut self, i: usize) {
490        if self.undo.is_none() {
491            return;
492        }
493        if !self.undo_seen.insert(i) {
494            return;
495        }
496        let prior = self.entries.get(i).cloned().flatten();
497        if let Some(log) = self.undo.as_mut() {
498            log.push((i, prior));
499        }
500    }
501
502    /// Tombstone slot `i`, keeping `live` in step.
503    ///
504    /// Out of range is a caller bug, not a condition: slot indices come from
505    /// `iter_slots` or `push_slot` and are internal throughout. It cannot
506    /// panic in release — silently skipping is better than aborting on a
507    /// ledger — but it must not pass unnoticed in a test run, or a desynced
508    /// `live` count shows up later as a wrong compaction trigger with nothing
509    /// pointing back here.
510    fn set_dead(&mut self, i: usize) {
511        self.record(i);
512        debug_assert!(
513            i < self.entries.len(),
514            "set_dead called with out-of-range slot {i} (of {})",
515            self.entries.len(),
516        );
517        if let Some(entry) = self.entries.get_mut(i)
518            && entry.take().is_some()
519        {
520            self.live -= 1;
521        }
522    }
523}
524
525/// Iterator over [`PositionStore`] yielding each live position with its SLOT
526/// index — the index [`std::ops::Index`] accepts, not a running count.
527///
528/// A stack-allocated enum for the same reason as [`PositionStoreIter`]: this
529/// runs inside every reduction and must not box.
530enum SlotIter<'a> {
531    Owned(std::iter::Enumerate<std::slice::Iter<'a, Option<Position>>>),
532    Shared(
533        std::iter::Enumerate<imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>>,
534    ),
535}
536
537impl<'a> SlotIter<'a> {
538    fn new(store: &'a PositionStore) -> Self {
539        match store {
540            PositionStore::Owned(v) => Self::Owned(v.entries.iter().enumerate()),
541            PositionStore::Shared(v) => Self::Shared(v.iter().enumerate()),
542        }
543    }
544}
545
546impl<'a> Iterator for SlotIter<'a> {
547    type Item = (usize, &'a Position);
548
549    fn next(&mut self) -> Option<Self::Item> {
550        match self {
551            Self::Owned(i) => {
552                // Skip tombstones, but keep the REAL slot number of what we do
553                // yield: that number is what comes back through `Index`.
554                for (slot, entry) in i.by_ref() {
555                    if let Some(position) = entry {
556                        return Some((slot, position));
557                    }
558                }
559                None
560            }
561            Self::Shared(i) => i.next(),
562        }
563    }
564}
565
566impl Default for PositionStore {
567    fn default() -> Self {
568        Self::Owned(Slots::default())
569    }
570}
571
572impl PositionStore {
573    /// Every live position paired with the index that [`Index`] will accept
574    /// for it.
575    ///
576    /// Today this is exactly `iter().enumerate()`, because every element of
577    /// the backing store is live. It exists as its own method because that
578    /// equivalence is a PROPERTY OF THE CURRENT STORAGE, not a law: the
579    /// reduction paths collect indices here and hand them back through
580    /// `Index`/`IndexMut`, so anything that makes the backing sparse — the
581    /// tombstoned lots that would let a cost-keyed index survive removals —
582    /// silently desynchronises the two unless every such site goes through
583    /// one place. This is that place.
584    ///
585    /// [`Index`]: std::ops::Index
586    fn iter_slots(&self) -> impl Iterator<Item = (usize, &Position)> {
587        // Real slot numbers, skipping tombstones — NOT a running count of live
588        // positions. The reduction paths hand these back to `Index`.
589        SlotIter::new(self)
590    }
591
592    /// Live positions, dropping tombstones. This is what `Serialize`, the FFI
593    /// converters and every external consumer see, so a drained lot stays
594    /// invisible exactly as it was when removal shifted the vector.
595    fn iter(&self) -> PositionStoreIter<'_> {
596        match self {
597            Self::Owned(v) => PositionStoreIter::Owned(v.entries.iter().flatten()),
598            Self::Shared(v) => PositionStoreIter::Shared(v.iter()),
599        }
600    }
601
602    /// Number of LIVE positions. Tombstones are not positions.
603    fn len(&self) -> usize {
604        match self {
605            Self::Owned(v) => v.live,
606            Self::Shared(v) => v.len(),
607        }
608    }
609
610    /// Number of slots, live or not — the exclusive upper bound on a valid
611    /// slot index, and what `push_slot` returns for the slot it fills.
612    fn slot_count(&self) -> usize {
613        match self {
614            Self::Owned(v) => v.entries.len(),
615            Self::Shared(v) => v.len(),
616        }
617    }
618
619    /// How many slots are tombstones.
620    const fn dead(&self) -> usize {
621        match self {
622            Self::Owned(v) => v.entries.len() - v.live,
623            Self::Shared(_) => 0,
624        }
625    }
626
627    fn is_empty(&self) -> bool {
628        self.len() == 0
629    }
630
631    fn get(&self, i: usize) -> Option<&Position> {
632        match self {
633            Self::Owned(v) => v.entries.get(i).and_then(Option::as_ref),
634            Self::Shared(v) => v.get(i),
635        }
636    }
637
638    fn push(&mut self, p: Position) {
639        self.push_slot(p);
640    }
641
642    /// Append a position, returning the slot index it landed in.
643    ///
644    /// The slot is `slot_count()`, not `len()`: with tombstones present those
645    /// differ, and `simple_index` stores slots.
646    fn push_slot(&mut self, p: Position) -> usize {
647        let slot = self.slot_count();
648        match self {
649            Self::Owned(v) => {
650                if let Some(log) = v.undo.as_mut() {
651                    log.push((slot, None));
652                    v.undo_seen.insert(slot);
653                }
654                v.entries.push(Some(p));
655                v.live += 1;
656            }
657            Self::Shared(v) => v.push_back(p),
658        }
659        slot
660    }
661
662    /// Remove the position in slot `i`, leaving a tombstone behind so no other
663    /// slot is renumbered.
664    fn remove(&mut self, i: usize) {
665        match self {
666            Self::Owned(v) => v.set_dead(i),
667            Self::Shared(v) => {
668                v.remove(i);
669            }
670        }
671    }
672
673    /// Drop positions failing `f`. On the sparse backing they become
674    /// tombstones, so no surviving lot is renumbered.
675    fn retain(&mut self, mut f: impl FnMut(&Position) -> bool) {
676        match self {
677            Self::Owned(v) => {
678                for i in 0..v.entries.len() {
679                    if v.entries[i].as_ref().is_some_and(|p| !f(p)) {
680                        v.set_dead(i);
681                    }
682                }
683            }
684            Self::Shared(v) => v.retain(f),
685        }
686    }
687
688    /// Start recording an undo log, discarding any previous one.
689    fn begin_undo(&mut self) {
690        if let Self::Owned(v) = self {
691            v.undo = Some(Vec::new());
692            v.undo_seen.clear();
693        }
694    }
695
696    /// Stop recording and discard the log — the transaction committed.
697    fn commit_undo(&mut self) {
698        if let Self::Owned(v) = self {
699            v.undo = None;
700            v.undo_seen.clear();
701        }
702    }
703
704    /// Restore every slot the log captured, newest first, and stop recording.
705    ///
706    /// Newest first, for two reasons that are easy to conflate.
707    ///
708    /// The visible one: slots CREATED by this transaction are popped from the
709    /// end while they are still last, so failed transactions do not leave dead
710    /// slots behind. Forward order would merely leave tombstones that
711    /// compaction reclaims, so a mutation swapping the order survives the
712    /// suite — expected, not a coverage gap.
713    ///
714    /// The load-bearing one: reverse order is what makes a slot recorded MORE
715    /// THAN ONCE safe. The earliest capture holds the true prior value, and
716    /// reverse iteration applies it last, so it wins. `record` deduplicates, so
717    /// duplicates should not arise — but the two mechanisms are independent,
718    /// and dropping BOTH is what would corrupt a rollback. Changing this to
719    /// forward order is only safe while the deduplication holds.
720    fn rollback_undo(&mut self) {
721        let Self::Owned(v) = self else {
722            return;
723        };
724        let Some(log) = v.undo.take() else {
725            return;
726        };
727        v.undo_seen.clear();
728        for (slot, prior) in log.into_iter().rev() {
729            // The slot existed: put back exactly what was there. Otherwise it
730            // was created by this transaction (or was already a tombstone), so
731            // it must end up not-live — dropped entirely when it is the last
732            // one, so slot numbers do not drift upward across failed
733            // transactions.
734            if let Some(position) = prior {
735                if v.entries[slot].is_none() {
736                    v.live += 1;
737                }
738                v.entries[slot] = Some(position);
739            } else {
740                if v.entries[slot].is_some() {
741                    v.live -= 1;
742                }
743                v.entries[slot] = None;
744                if slot + 1 == v.entries.len() {
745                    v.entries.pop();
746                }
747            }
748        }
749    }
750
751    /// Physically drop tombstones, renumbering the slots that remain.
752    ///
753    /// INVALIDATES every slot index, so callers must hold none and must
754    /// rebuild anything keyed by slot. Reductions never span this: it runs
755    /// before planning, when nothing is held.
756    fn compact_slots(&mut self) {
757        if let Self::Owned(v) = self {
758            v.entries.retain(Option::is_some);
759            debug_assert_eq!(
760                v.entries.len(),
761                v.live,
762                "compaction must leave only live slots"
763            );
764        }
765    }
766
767    /// [`Self::retain`], with each position's slot index — the same index
768    /// [`Self::iter_slots`] reports and [`Index`] accepts.
769    ///
770    /// `reduce_merge` needs this: it selects lots through `iter_slots` and
771    /// then drops exactly those. Written with a plain `retain` and a counter
772    /// incremented per visit, that is only correct while every element the
773    /// store holds is a live position visited in order — the same dense-store
774    /// assumption `iter_slots` exists to keep in one place, arriving by a
775    /// second route that `iter_slots` cannot cover. A sparse store whose
776    /// `retain` skips dead slots would leave the counter numbering live
777    /// positions while the selection numbered real slots, and `{*}` merges
778    /// would delete the wrong lots.
779    ///
780    /// [`Index`]: std::ops::Index
781    fn retain_slots(&mut self, mut f: impl FnMut(usize, &Position) -> bool) {
782        match self {
783            Self::Owned(v) => {
784                for i in 0..v.entries.len() {
785                    if v.entries[i].as_ref().is_some_and(|p| !f(i, p)) {
786                        v.set_dead(i);
787                    }
788                }
789            }
790            // Dense: a running count IS the slot number here.
791            Self::Shared(v) => {
792                let mut slot = 0;
793                v.retain(|position| {
794                    let keep = f(slot, position);
795                    slot += 1;
796                    keep
797                });
798            }
799        }
800    }
801
802    /// Switch to contiguous storage, cloning if not already `Owned`.
803    ///
804    /// `reduce` calls this, which ALSO discharges the uniqueness requirement
805    /// the old unconditional `self.positions.iter().cloned().collect()`
806    /// existed for: mutating a structurally-SHARED `imbl::Vector` in place
807    /// drives `imbl-sized-chunks`' copy-on-write into a use-after-free of the
808    /// interned `Arc<str>` inside `Position`. Materializing into a fresh
809    /// `Vec` leaves nothing shared to corrupt, at the same O(M) cost that
810    /// copy already paid — and every subsequent access in the reduction is
811    /// then contiguous instead of an RRB walk.
812    fn make_owned(&mut self) {
813        if let Self::Shared(v) = self {
814            let slots: Vec<Option<Position>> = v.iter().cloned().map(Some).collect();
815            let live = slots.len();
816            *self = Self::Owned(Slots {
817                entries: slots,
818                live,
819                undo: None,
820                undo_seen: rustc_hash::FxHashSet::default(),
821            });
822        }
823    }
824}
825
826impl std::ops::Index<usize> for PositionStore {
827    type Output = Position;
828    /// # Panics
829    ///
830    /// Panics if `i` names a tombstone. Every index in circulation comes from
831    /// [`PositionStore::iter_slots`] or [`PositionStore::push_slot`], which
832    /// only ever report live slots, and no reduction removes a lot and then
833    /// re-reads it — so reaching a tombstone means slot numbers and the store
834    /// have desynchronised, and a wrong-lot read is worse than a panic.
835    fn index(&self, i: usize) -> &Position {
836        match self {
837            Self::Owned(v) => v.entries[i]
838                .as_ref()
839                .expect("slot index names a live position, not a tombstone"),
840            Self::Shared(v) => &v[i],
841        }
842    }
843}
844
845impl std::ops::IndexMut<usize> for PositionStore {
846    /// # Panics
847    ///
848    /// As [`Index::index`](std::ops::Index::index).
849    fn index_mut(&mut self, i: usize) -> &mut Position {
850        match self {
851            Self::Owned(v) => {
852                // Capture BEFORE the caller writes through the returned
853                // reference — afterwards the prior value is gone.
854                v.record(i);
855                v.entries[i]
856                    .as_mut()
857                    .expect("slot index names a live position, not a tombstone")
858            }
859            Self::Shared(v) => &mut v[i],
860        }
861    }
862}
863
864impl FromIterator<Position> for PositionStore {
865    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
866        let slots: Vec<Option<Position>> = iter.into_iter().map(Some).collect();
867        let live = slots.len();
868        Self::Owned(Slots {
869            entries: slots,
870            live,
871            undo: None,
872            undo_seen: rustc_hash::FxHashSet::default(),
873        })
874    }
875}
876
877// Serialized as a plain sequence, identical for both backings — the wire
878// format does not encode which representation happens to be in use, and a
879// round-trip always lands in `Owned` (deserialization is followed by
880// `rebuild_index`, and a freshly-loaded inventory is about to be mutated far
881// more often than snapshotted).
882impl Serialize for PositionStore {
883    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
884        serializer.collect_seq(self.iter())
885    }
886}
887
888impl<'de> Deserialize<'de> for PositionStore {
889    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
890        Ok(Self::Owned(Slots::from_live(Vec::<Position>::deserialize(
891            deserializer,
892        )?)))
893    }
894}
895
896/// An inventory is a collection of positions.
897///
898/// It tracks all positions for an account and supports booking operations
899/// for adding and reducing positions.
900///
901/// # Examples
902///
903/// ```
904/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
905/// use rust_decimal_macros::dec;
906///
907/// let mut inv = Inventory::new();
908///
909/// // Add a simple position
910/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
911/// assert_eq!(inv.units("USD"), dec!(100));
912///
913/// // Add a position with cost
914/// let cost = Cost::new(dec!(150.00), "USD");
915/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
916/// assert_eq!(inv.units("AAPL"), dec!(10));
917/// ```
918#[derive(Debug, Clone, Default, Serialize, Deserialize)]
919// Deserialization goes through `InventoryWire` so the derived caches are
920// REBUILT rather than left empty.
921//
922// `simple_index` and `units_cache` are `#[serde(skip)]`, so a plain derive
923// produced an inventory holding positions with both caches empty. `units()`
924// recomputes on a miss and `add_headroom_for` refuses to answer, but `add()`
925// trusted them: `units_cache.get(..).unwrap_or_default()` read 0 for an
926// inventory already holding 100 USD, then wrote that back as the new total,
927// while the empty `simple_index` meant a cost-less lot was appended instead of
928// merged. A round-tripped 100 USD inventory answered `units("USD") == 5` after
929// adding 5, with two lots where there should be one.
930//
931// `rebuild_index`'s own doc already claimed it ran "after ... deserialization".
932// It did not — nothing called it on that path, and two comments elsewhere
933// referred to it by a name (`rebuild_caches`) that never existed. Now it does.
934#[serde(try_from = "InventoryWire")]
935pub struct Inventory {
936    /// Positions, in whichever backing suits this inventory's use — see
937    /// [`PositionStore`]. Contiguous (`Owned`) by default, which is what
938    /// booking wants; structurally shared (`Shared`) for the BQL running
939    /// balances that are cloned once per output row.
940    ///
941    /// The notes below describe the SHARED backing, and are why it still
942    /// exists:
943    /// This is the critical property for JOURNAL-style row-per-snapshot
944    /// patterns in BQL (issue #1086): N nested snapshots cost O(base + Σ
945    /// deltas) memory instead of O(N · base), and the per-row clone cost
946    /// drops from O(positions) to O(1).
947    ///
948    /// The trade is real: booking and BQL aggregator mutations pay an
949    /// O(log N) tree walk vs `Vec`'s amortized O(1) push. Measured impact
950    /// scales with inventory size M: +85 ns/op at M=10, +1.6 µs/op at
951    /// M=100, +19 µs/op at M=500 (criterion `reduce_fifo/*`). For typical
952    /// small-M ledgers the overhead is sub-millisecond per `rledger
953    /// check`; the users who feel it are users with very large inventories,
954    /// the same users who hit the JOURNAL OOM today.
955    ///
956    /// `rkyv` derives were dropped because (a) `imbl::Vector` has no `rkyv`
957    /// impl and (b) no code path currently archives an `Inventory`
958    /// (confirmed in the `SmallVec` experiment for #1069). Pre-1.0 break;
959    /// downstream callers archiving `Inventory` directly will need to
960    /// archive `Vec<Position>` themselves. Serde wire format is unchanged
961    /// (sequence-typed, identical for both backings).
962    positions: PositionStore,
963    /// Index for O(1) lookup of simple positions (no cost) by currency.
964    /// Maps currency to position index in the `positions` vector.
965    /// Cache of total units per currency for O(1) `units()` lookups.
966    /// Updated incrementally on `add()` and `reduce()`.
967    /// Not serialized - rebuilt on demand.
968    #[serde(skip)]
969    units_cache: FxHashMap<crate::Currency, CurrencyStats>,
970    /// Cost-bearing lots grouped by what a cost spec matches them on, so a
971    /// reduction naming an explicit per-unit cost finds its candidates instead
972    /// of comparing against every lot.
973    ///
974    /// That comparison was the last superlinear term in the pipeline:
975    /// `CostSpec::matches` ran once per lot per reduction and grew 111x for
976    /// 10x the input. Slots are stable across removals — that is what the
977    /// tombstoned backing buys — so the lists stay valid until compaction,
978    /// which rebuilds them.
979    ///
980    /// Only lots WITH a cost appear. A spec that names no per-unit cost still
981    /// scans, because it can match anything.
982    /// Not serialized - rebuilt on demand, like the caches above.
983    #[serde(skip)]
984    cost_index: FxHashMap<CostKey, smallvec::SmallVec<[usize; 2]>>,
985    /// EVERY lot per units-currency, in the order FIFO consumes them: lot date
986    /// ascending, ties broken by slot ascending.
987    ///
988    /// Cost-LESS lots are in here too, and deliberately. An empty cost spec
989    /// matches one (`matches_cost_spec`: `(None, true) => true`), so ordered
990    /// selection can drain one — an index holding only cost-bearing lots chose
991    /// a different lot than the scan it replaced, which is what the
992    /// scan-equivalence test caught. `cost_index` is the map keyed on cost;
993    /// this one is keyed on nothing but the commodity.
994    ///
995    /// `cost_index` cannot serve an under-specified spec — a bare `{}` names
996    /// no cost to key on — so ordered selection scanned every slot instead,
997    /// once per reduction. Walking this list stops as soon as the reduction is
998    /// covered, so the common single-lot sale touches one entry (#2083).
999    ///
1000    /// Same safety asymmetry as `cost_index`: a STALE entry is harmless
1001    /// because the walk re-checks liveness, sign and the spec, while a MISSING
1002    /// entry hides a lot the reduction should have seen. Insertion is at the
1003    /// single `add` site; removal rides on `cost_index_remove`.
1004    /// Not serialized - rebuilt on demand, like the caches above.
1005    /// `None` until ordered selection asks for it: boxed so an inventory that
1006    /// never needs one carries a pointer rather than a map. The map itself is
1007    /// three words plus its allocation, on a type that is created per account
1008    /// and cloned per BQL output row.
1009    #[serde(skip)]
1010    ordered_index: Option<Box<OrderedIndex>>,
1011    /// Whether an undo log is open. Not serialized; a transaction never spans
1012    /// a round trip.
1013    #[serde(skip)]
1014    undo_open: bool,
1015    /// Debug-only copy taken at `begin_undo`, compared against the restored
1016    /// inventory to prove the log covered every mutation.
1017    #[cfg(debug_assertions)]
1018    #[serde(skip)]
1019    undo_witness: Option<Box<Self>>,
1020}
1021
1022/// The order a booking method consumes lots in.
1023///
1024/// One inventory needs one ordering, because an account has one booking
1025/// method — so this is stored alongside the index rather than a second index
1026/// being kept in step with the first.
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028pub(super) enum LotOrder {
1029    /// Oldest lot first. FIFO walks it forward, LIFO backward.
1030    Date,
1031    /// Most expensive lot first, which is what HIFO takes.
1032    CostDescending,
1033}
1034
1035/// Lots per units-currency, in the order some booking method consumes them.
1036#[derive(Debug, Clone)]
1037pub(super) struct OrderedIndex {
1038    /// Which ordering `by_currency` is sorted in.
1039    order: LotOrder,
1040    /// Slots per units-currency, sorted by `order` then slot ascending.
1041    by_currency: FxHashMap<crate::Currency, Vec<usize>>,
1042}
1043
1044/// What [`Inventory::cost_index`] groups lots by: the units they are held in,
1045/// and the per-unit cost a spec would name to select them.
1046type CostKey = (crate::Currency, Decimal, crate::Currency);
1047
1048/// The key for `position`, if it carries a cost.
1049fn cost_key(position: &Position) -> Option<CostKey> {
1050    position.cost.as_ref().map(|cost| {
1051        (
1052            position.units.currency.clone(),
1053            cost.number,
1054            cost.currency.clone(),
1055        )
1056    })
1057}
1058
1059/// Everything cached per currency: the running unit total, and the per-bucket
1060/// position counts that make [`Inventory::is_reduced_by`] O(1).
1061///
1062/// Deliberately ONE map rather than two. `add` already did a `get` plus an
1063/// `insert` on the units cache, and hanging a second map off the same key
1064/// added a third hash of an interned string per posting — which measured as a
1065/// 4% regression on the cost-spec-free `simple` profiling shape, wiping out
1066/// part of what the index bought on `investment`. Folded in here, the counts
1067/// ride along on a lookup that was already happening.
1068#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1069struct CurrencyStats {
1070    /// Running total of units across every lot of this currency.
1071    total: Decimal,
1072    /// Position counts by (sign, cost-bearing) bucket.
1073    counts: SignCounts,
1074    /// Slot of the single cost-less lot of this currency, if there is one —
1075    /// the lot a later cost-less `add` merges into.
1076    ///
1077    /// Folded in here rather than kept in its own map because every reader
1078    /// wants it alongside the totals: `add` looked the currency up once for
1079    /// the total and again for this, and `add_headroom_for` did the same, so
1080    /// each posting hashed the same interned string twice for no reason. The
1081    /// two have identical lifetimes — neither is ever pruned, both are
1082    /// `#[serde(skip)]` and both are rebuilt together by `rebuild_caches` —
1083    /// so there was never a state one could describe and the other could not.
1084    simple_slot: Option<usize>,
1085}
1086
1087/// How many positions of a currency fall in each (sign, cost-bearing) bucket.
1088///
1089/// Buckets keyed on `Decimal::is_sign_positive`, which is the exact predicate
1090/// [`Inventory::is_reduced_by`] uses — note it answers `true` for zero, and
1091/// the scan it replaces counted empty positions too, so this must as well.
1092#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1093struct SignCounts {
1094    /// Cost-bearing lots whose units are sign-positive.
1095    cost_positive: u32,
1096    /// Cost-bearing lots whose units are sign-negative.
1097    cost_negative: u32,
1098    /// Cost-less lots whose units are sign-positive.
1099    simple_positive: u32,
1100    /// Cost-less lots whose units are sign-negative.
1101    simple_negative: u32,
1102}
1103
1104impl SignCounts {
1105    /// Count of positions in the bucket opposite to `units_is_positive`.
1106    const fn opposite(self, units_is_positive: bool, scope: ReductionScope) -> u32 {
1107        let (cost, simple) = if units_is_positive {
1108            (self.cost_negative, self.simple_negative)
1109        } else {
1110            (self.cost_positive, self.simple_positive)
1111        };
1112        match scope {
1113            // Saturating, not `+`: these are counts of lots and a wrap would
1114            // read as "no matching lot", which books a reduction as an
1115            // augmentation and duplicates the lot. Saturation errs the other
1116            // way, and `> 0` is all the caller asks.
1117            ReductionScope::AllPositions => cost.saturating_add(simple),
1118            ReductionScope::CostBearingOnly => cost,
1119        }
1120    }
1121
1122    /// `delta` is `i32` rather than `i64` so it feeds `saturating_add_signed`
1123    /// directly. The earlier `i64` version ended in `try_into().unwrap_or(0)`,
1124    /// which turns a caller mistake into a silent no-op — the one outcome that
1125    /// leaves the counts wrong with nothing to show for it.
1126    fn bump(&mut self, has_cost: bool, is_positive: bool, delta: i32) {
1127        debug_assert!(
1128            delta == 1 || delta == -1,
1129            "counts move one lot at a time; {delta} means a caller lost track",
1130        );
1131        let slot = match (has_cost, is_positive) {
1132            (true, true) => &mut self.cost_positive,
1133            (true, false) => &mut self.cost_negative,
1134            (false, true) => &mut self.simple_positive,
1135            (false, false) => &mut self.simple_negative,
1136        };
1137        // Saturating: an under-count can only make `is_reduced_by` answer
1138        // "not a reduction" and fall back to augmentation, where a wrapped
1139        // u32 would claim billions of matching lots.
1140        *slot = slot.saturating_add_signed(delta);
1141    }
1142}
1143
1144/// Where the positions a cache rebuild is reading came from.
1145///
1146/// Only affects whether the one-cost-less-lot-per-currency invariant is
1147/// ASSERTED. It is a genuine invariant of positions this type built, and a
1148/// `debug_assert` there earns its keep as an internal-bug tripwire — but a
1149/// deserialized payload is input, and input must not be able to panic us.
1150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1151enum CacheSource {
1152    /// Positions this inventory produced; the invariant holds.
1153    Internal,
1154    /// Positions from outside — a deserialized payload.
1155    Untrusted,
1156}
1157
1158/// Deserialization shape for [`Inventory`]: the persisted field only.
1159///
1160/// Exists so `From` can rebuild the derived caches — see the note on
1161/// `Inventory`. Kept private; the wire format is unchanged (a struct with a
1162/// `positions` sequence), so this is not a compatibility break.
1163#[derive(Deserialize)]
1164struct InventoryWire {
1165    // NOT `#[serde(default)]`. The derive this replaces made `positions`
1166    // required, so `{}` was `Err("missing field `positions`")`; defaulting it
1167    // would quietly accept a malformed payload as an empty inventory.
1168    positions: Vector<Position>,
1169}
1170
1171impl TryFrom<InventoryWire> for Inventory {
1172    type Error = OverflowError;
1173
1174    /// `TryFrom`, not `From`: rebuilding the caches sums a currency's positions,
1175    /// and that sum can overflow on a payload nobody sane wrote.
1176    ///
1177    /// `rebuild_index` accumulates with `+=`, which PANICS on `Decimal`
1178    /// overflow — so two `Decimal::MAX` USD lots aborted inside `Deserialize`
1179    /// with "Addition overflowed" rather than returning a serde error. Review
1180    /// catch; a deserialization boundary must not panic on its input, the same
1181    /// rule that applies to the parser. The rebuild now uses `checked_add` and
1182    /// the failure arrives as `Err`, which serde reports as a normal
1183    /// deserialization error.
1184    fn try_from(wire: InventoryWire) -> Result<Self, Self::Error> {
1185        let mut inv = Self {
1186            positions: PositionStore::Owned(Slots::from_live(wire.positions.into_iter().collect())),
1187            units_cache: FxHashMap::default(),
1188            cost_index: FxHashMap::default(),
1189            ordered_index: None,
1190            undo_open: false,
1191            #[cfg(debug_assertions)]
1192            undo_witness: None,
1193        };
1194        // UNTRUSTED: the payload is input, not something this type produced, so
1195        // it may carry two cost-less lots for one currency — a state the
1196        // invariant forbids. `rebuild_index`'s `debug_assert` is there to catch
1197        // an internal bug; reaching it from deserialization would turn a
1198        // malformed document into a panic at the boundary.
1199        inv.try_rebuild_index_from(CacheSource::Untrusted)?;
1200        Ok(inv)
1201    }
1202}
1203
1204impl PartialEq for Inventory {
1205    fn eq(&self, other: &Self) -> bool {
1206        // Only compare positions, not the index (which is derived data)
1207        self.positions.iter().eq(other.positions.iter())
1208    }
1209}
1210
1211impl Eq for Inventory {}
1212
1213impl Inventory {
1214    /// Create an empty inventory.
1215    #[must_use]
1216    pub fn new() -> Self {
1217        Self::default()
1218    }
1219
1220    /// Iterate over all positions.
1221    ///
1222    /// Previously returned `&[Position]`; now returns an iterator
1223    /// because the underlying storage is a tree-based persistent
1224    /// vector (`imbl::Vector`) that doesn't expose a contiguous slice.
1225    /// Most callers already iterate — for callers that need
1226    /// random-access / indexed / `.len()` slice semantics, see
1227    /// [`Self::position_list`].
1228    pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
1229        self.positions.iter()
1230    }
1231
1232    /// Materialize all positions as a `Vec<&Position>` for slice-style
1233    /// access (indexing, `.len()`, `.first()`, `.is_empty()`).
1234    ///
1235    /// Allocates `O(N)` pointers per call. Callers that only iterate
1236    /// once should use [`Self::positions`] instead — this is for code
1237    /// paths that need slice semantics.
1238    #[must_use]
1239    pub fn position_list(&self) -> Vec<&Position> {
1240        self.positions.iter().collect()
1241    }
1242
1243    /// Drop tombstones once they outnumber live lots, so slots stay within 2x
1244    /// the real position count and iteration cannot degrade toward "every lot
1245    /// this account ever held".
1246    ///
1247    /// Renumbers slots, so it must not run while an undo log is open or while
1248    /// any caller holds a slot index. The engine calls it after a transaction
1249    /// commits, which is the one moment both hold.
1250    ///
1251    /// Amortized: each compaction is O(slots) but halves them, so the cost per
1252    /// removed lot is constant.
1253    ///
1254    /// # Panics
1255    ///
1256    /// Panics in debug builds if an undo log is open.
1257    pub fn compact_if_sparse(&mut self) {
1258        debug_assert!(
1259            !self.undo_open,
1260            "compact_if_sparse would renumber slots the open undo log refers to",
1261        );
1262        if self.positions.dead() > self.positions.len() {
1263            self.positions.compact_slots();
1264            self.rebuild_index();
1265        }
1266    }
1267
1268    /// Begin recording an undo log so a failed transaction can be reverted
1269    /// without having copied this inventory.
1270    ///
1271    /// `apply` used to snapshot every touched account with `Inventory::clone`.
1272    /// That was written when the backing was `imbl::Vector` and the clone was
1273    /// O(1); since #2056 booking's backing is owned, so it became O(lots) per
1274    /// touched account per transaction — the largest superlinear term left in
1275    /// the pipeline, worth 56% of a 20,000-transaction `investment` run.
1276    ///
1277    /// A reduction touches one or two lots. Recording those is proportional to
1278    /// what changed instead of to what the account holds.
1279    ///
1280    /// # Panics
1281    ///
1282    /// Panics in debug builds if a log is already open — nesting would make
1283    /// "restore to the start" ambiguous.
1284    pub fn begin_undo(&mut self) {
1285        debug_assert!(
1286            !self.undo_open,
1287            "begin_undo called twice without commit or rollback",
1288        );
1289        // Recording only exists on the owned backing. A shared inventory would
1290        // set `undo_open` while capturing nothing, and rollback would then
1291        // restore nothing while reporting success — silent corruption rather
1292        // than a visible failure.
1293        //
1294        // Unreachable today: `BookingEngine` populates its map solely through
1295        // `entry().or_default()`, which is owned, and accepts no inventory from
1296        // outside. Asserted so it stays that way.
1297        debug_assert!(
1298            matches!(self.positions, PositionStore::Owned(_)),
1299            "begin_undo on a shared inventory would record nothing and roll \
1300             back nothing",
1301        );
1302        self.undo_open = true;
1303        #[cfg(debug_assertions)]
1304        {
1305            // The log is only as good as its coverage of the mutation paths.
1306            // Recording lives in the backing's primitives, which is complete by
1307            // construction today — this keeps it honest if that ever stops
1308            // being true, at a cost paid only in debug builds.
1309            self.undo_witness = Some(Box::new(self.clone_for_witness()));
1310        }
1311        self.positions.begin_undo();
1312    }
1313
1314    /// Whether an undo log is currently open.
1315    #[must_use]
1316    pub const fn undo_is_open(&self) -> bool {
1317        self.undo_open
1318    }
1319
1320    /// Discard the log — the transaction committed.
1321    pub fn commit_undo(&mut self) {
1322        self.undo_open = false;
1323        #[cfg(debug_assertions)]
1324        {
1325            self.undo_witness = None;
1326        }
1327        self.positions.commit_undo();
1328    }
1329
1330    /// Restore this inventory to its state at [`Self::begin_undo`].
1331    ///
1332    /// Rebuilds the derived caches wholesale rather than unwinding them: this
1333    /// is the failure path, so being obviously right beats being fast.
1334    ///
1335    /// # Panics
1336    ///
1337    /// Panics in debug builds if the result differs from a witness copy taken
1338    /// at `begin_undo` — that means a mutation path bypassed the log.
1339    pub fn rollback_undo(&mut self) {
1340        self.undo_open = false;
1341        self.positions.rollback_undo();
1342        self.rebuild_index();
1343        #[cfg(debug_assertions)]
1344        {
1345            if let Some(witness) = self.undo_witness.take() {
1346                let restored: Vec<&Position> = self.positions.iter().collect();
1347                let expected: Vec<&Position> = witness.positions.iter().collect();
1348                assert_eq!(
1349                    restored, expected,
1350                    "rollback did not restore the inventory: some mutation path \
1351                     did not go through the backing's primitives, so the undo \
1352                     log missed it",
1353                );
1354            }
1355        }
1356    }
1357
1358    /// A copy for the debug-only rollback witness.
1359    #[cfg(debug_assertions)]
1360    fn clone_for_witness(&self) -> Self {
1361        let mut copy = self.clone();
1362        copy.undo_witness = None;
1363        copy.undo_open = false;
1364        copy
1365    }
1366
1367    /// Rewrite the positions wholesale, then rebuild every derived cache.
1368    ///
1369    /// Replaces the old `positions_mut`, which handed out `&mut Vec<Position>`
1370    /// directly. That is no longer possible — the backing is sparse, so the
1371    /// vector holds `Option<Position>` alongside a live count, and a caller
1372    /// writing through it could desync that count with no way to notice.
1373    /// It also left `units_cache` and `simple_index` describing the OLD
1374    /// contents, which this rebuilds for you.
1375    ///
1376    /// Pre-1.0 break: the closure sees a dense `Vec<Position>` with tombstones
1377    /// already dropped, and whatever it leaves becomes the inventory.
1378    pub fn modify_positions(&mut self, f: impl FnOnce(&mut Vec<Position>)) {
1379        let mut dense: Vec<Position> = self.positions.iter().cloned().collect();
1380        f(&mut dense);
1381        self.positions = PositionStore::Owned(Slots::from_live(dense));
1382        self.rebuild_index();
1383    }
1384
1385    /// An inventory whose positions are structurally SHARED.
1386    ///
1387    /// For accumulators that are cloned far more often than they are mutated
1388    /// — BQL's JOURNAL running balance, which emits one snapshot per output
1389    /// row. Cloning is O(1) and successive snapshots share structure, so N
1390    /// rows cost O(base + sum of deltas) instead of O(N x base) (#1086).
1391    ///
1392    /// Everything else should use [`Inventory::new`]: the default contiguous
1393    /// backing is what makes booking's `reduce` cheap, and `reduce` converts
1394    /// to it anyway.
1395    #[must_use]
1396    pub fn new_shared() -> Self {
1397        Self {
1398            positions: PositionStore::Shared(Vector::new()),
1399            ..Self::default()
1400        }
1401    }
1402
1403    /// Check if inventory is empty.
1404    #[must_use]
1405    pub fn is_empty(&self) -> bool {
1406        self.positions.is_empty()
1407            || self
1408                .positions
1409                .iter()
1410                .all(super::position::Position::is_empty)
1411    }
1412
1413    /// Get the number of positions (including empty ones).
1414    #[must_use]
1415    pub fn len(&self) -> usize {
1416        self.positions.len()
1417    }
1418
1419    /// Get total units of a currency (ignoring cost lots).
1420    ///
1421    /// This sums all positions of the given currency regardless of cost basis.
1422    /// Uses an internal cache for O(1) lookups.
1423    #[must_use]
1424    pub fn units(&self, currency: &str) -> Decimal {
1425        // Use the cache when it is there. A miss is not a bug: the cache is
1426        // `#[serde(skip)]`, and while deserialization rebuilds it (the
1427        // `#[serde(try_from = "InventoryWire"]` on the struct), an inventory
1428        // built some other way may not have one yet. Recomputing is O(lots)
1429        // but always right.
1430        //
1431        // (This used to point callers at `rebuild_caches()`, which has never
1432        // existed under that name — `rebuild_index` is the real one, and
1433        // callers do not need it on the deserialize path any more.)
1434        self.units_cache.get(currency).map_or_else(
1435            || {
1436                // Fallback to computation if cache miss (e.g., after deserialization)
1437                self.positions
1438                    .iter()
1439                    .filter(|p| p.units.currency == currency)
1440                    .map(|p| p.units.number)
1441                    .sum()
1442            },
1443            |stats| stats.total,
1444        )
1445    }
1446
1447    /// Whether every `add` of `currency` totaling at most `needed` in absolute
1448    /// value is guaranteed not to overflow.
1449    ///
1450    /// `add` overflows at exactly two `checked_add`s: the per-currency running
1451    /// total, and — for a cost-less position — the single merged lot that
1452    /// `simple_index` points at. Both operands are bounded here against
1453    /// `needed`, so a `true` answer means no sequence of adds whose magnitudes
1454    /// sum to `needed` can overflow either, at any intermediate step: every
1455    /// partial sum is bounded by the total.
1456    ///
1457    /// Conservative by construction — `false` only ever means "cannot prove
1458    /// it", never "will overflow". Callers use it to skip work that exists
1459    /// solely to recover from overflow (#1897).
1460    #[must_use]
1461    pub fn add_headroom_for(&self, currency: &str, needed: Decimal) -> bool {
1462        // Enforce the magnitude contract rather than trusting it. A negative
1463        // `needed` would make the sums below SMALLER and hand back `true` when
1464        // overflow is possible — and an unsound `true` here means `apply`
1465        // skips the snapshot it needed, so earlier postings of a failing
1466        // transaction cannot be rolled back. Cheap insurance on a `pub` method
1467        // whose failure mode is silent corruption.
1468        let needed = needed.abs();
1469
1470        // `units_cache` and `simple_index` are `#[serde(skip)]`, so a
1471        // deserialized inventory carries its positions with both caches empty
1472        // until `rebuild_caches` runs. Reading them in that state answers
1473        // "plenty of room" for an inventory sitting at the ceiling — an
1474        // unsound `true`, which is the one direction this method must never
1475        // fail in. Refuse to answer instead. `units()` handles the same gap by
1476        // recomputing from `positions`; that is O(positions) and this is meant
1477        // to be O(1), so the conservative answer is the right trade here.
1478        if self.units_cache.is_empty() && !self.positions.is_empty() {
1479            return false;
1480        }
1481
1482        // `checked_add` is the whole test: it returns `None` exactly when the
1483        // sum is not representable, so any `Some` it hands back is already a
1484        // `Decimal` and therefore already `<= Decimal::MAX`. Comparing against
1485        // the ceiling as well was a tautology, and not a free one — `Decimal`'s
1486        // `PartialOrd` aligns the scales of both operands before it can answer,
1487        // and `Decimal::MAX` has scale 0 and a full 96-bit mantissa, so it was
1488        // the most expensive shape that comparison has. This runs twice per
1489        // posting via `overflow_is_possible`.
1490        let fits = |v: Decimal| v.abs().checked_add(needed).is_some();
1491
1492        // One lookup for both halves: the running total and the cost-less lot
1493        // a merge would land in.
1494        //
1495        // No entry means this inventory holds nothing of `currency`, so
1496        // nothing can overflow — and it is the same answer the two-map version
1497        // gave, which fell through to the lot check here. That fall-through
1498        // could never find anything: `add` writes the totals before the slot,
1499        // `rebuild_caches` writes both in one pass, and only the slot is ever
1500        // cleared, so a recorded slot always had a stats entry beside it. The
1501        // wholly-empty cache of a just-deserialized inventory is refused
1502        // above, which is the case where this reasoning would not hold.
1503        let Some(stats) = self.units_cache.get(currency) else {
1504            return true;
1505        };
1506        if !fits(stats.total) {
1507            return false;
1508        }
1509        // Only a cost-less add merges, and `simple_slot` names the one lot it
1510        // would merge into.
1511        stats
1512            .simple_slot
1513            .and_then(|idx| self.positions.get(idx))
1514            .is_none_or(|lot| fits(lot.units.number))
1515    }
1516
1517    /// Get all currencies in this inventory.
1518    #[must_use]
1519    pub fn currencies(&self) -> Vec<&str> {
1520        let mut currencies: Vec<&str> = self
1521            .positions
1522            .iter()
1523            .filter(|p| !p.is_empty())
1524            .map(|p| p.units.currency.as_str())
1525            .collect();
1526        currencies.sort_unstable();
1527        currencies.dedup();
1528        currencies
1529    }
1530
1531    /// Check if the given units would reduce (not augment) this inventory.
1532    ///
1533    /// Returns `true` if there's a position with the same currency but opposite
1534    /// sign, meaning these units would reduce the inventory rather than add to it.
1535    ///
1536    /// When `has_cost_spec` is `true`, only positions **with** a cost basis are
1537    /// considered for reduction matching.  Simple (no-cost) positions are ignored
1538    /// because they live in a different "cost layer" — a sell-without-cost-spec
1539    /// that left a negative simple position should not cause a subsequent
1540    /// cost-bearing augmentation to be misclassified as a reduction.
1541    /// See: issue #875, beancount#889.
1542    ///
1543    /// This is used to determine whether a posting is a sale/reduction or a
1544    /// purchase/augmentation.
1545    #[must_use]
1546    pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
1547        // `units_cache` is `#[serde(skip)]` like `simple_index`. An empty
1548        // one over non-empty positions means it has not been built yet, and
1549        // reading it then would answer "not a reduction" for an inventory that
1550        // holds matching lots — booking the posting as an augmentation and
1551        // silently creating a duplicate lot. Fall back to the scan, as
1552        // `units()` does for the same gap.
1553        if self.units_cache.is_empty() && !self.positions.is_empty() {
1554            return self.is_reduced_by_scan(units, scope);
1555        }
1556
1557        let answer = self.units_cache.get(&units.currency).is_some_and(|stats| {
1558            stats
1559                .counts
1560                .opposite(units.number.is_sign_positive(), scope)
1561                > 0
1562        });
1563
1564        // The index is maintained incrementally by `add` and the reduction
1565        // commit paths; a missed update is a wrong answer, not a slow one.
1566        debug_assert_eq!(
1567            answer,
1568            self.is_reduced_by_scan(units, scope),
1569            "the cached sign counts disagree with a scan of positions — some \
1570             mutation path changed a lot without maintaining them",
1571        );
1572        answer
1573    }
1574
1575    /// The scan [`Self::is_reduced_by`] replaced, kept as the definition the
1576    /// index is checked against and as the fallback for an unbuilt index.
1577    fn is_reduced_by_scan(&self, units: &Amount, scope: ReductionScope) -> bool {
1578        self.positions.iter().any(|pos| {
1579            pos.units.currency == units.currency
1580                && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
1581                && match scope {
1582                    ReductionScope::AllPositions => true,
1583                    ReductionScope::CostBearingOnly => pos.cost.is_some(),
1584                }
1585        })
1586    }
1587
1588    /// Whether a posting of `units` carrying `cost` would REDUCE this inventory
1589    /// under `method` — the single source for the reduction-vs-augmentation
1590    /// decision shared by the booking engine (`BookingEngine::apply`) and the
1591    /// Late validator's inventory pass.
1592    ///
1593    /// A posting reduces only when it carries a cost spec (`cost.is_some()` —
1594    /// presence of the spec, which includes an empty/unresolved one like `{}`),
1595    /// the booking method isn't `NONE` (issue #1182 — `NONE` accumulates every
1596    /// posting as an augmentation, with no lot matching), and the inventory holds
1597    /// a cost-bearing position of the opposite sign in the same currency
1598    /// ([`Self::is_reduced_by`] with [`ReductionScope::CostBearingOnly`]). This
1599    /// gate was previously written byte-for-byte in both crates and the #1182 fix
1600    /// had to be applied twice.
1601    #[must_use]
1602    pub fn is_booking_reduction(
1603        &self,
1604        units: &Amount,
1605        cost: Option<&CostSpec>,
1606        method: BookingMethod,
1607    ) -> bool {
1608        method != BookingMethod::None
1609            && cost.is_some()
1610            && self.is_reduced_by(units, ReductionScope::CostBearingOnly)
1611    }
1612
1613    /// Get the total book value (cost basis) for a currency.
1614    ///
1615    /// Returns the sum of all cost bases for positions of the given currency.
1616    ///
1617    /// # Errors
1618    ///
1619    /// [`OverflowError`] when a position's book value, or the running
1620    /// per-currency total, leaves `rust_decimal`'s range.
1621    pub fn book_value(
1622        &self,
1623        units_currency: &str,
1624    ) -> Result<FxHashMap<crate::Currency, Decimal>, OverflowError> {
1625        let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
1626
1627        for pos in self.positions.iter() {
1628            if pos.units.currency == units_currency {
1629                // NOT `pos.book_value()`: its `None` conflates "no cost" with
1630                // "product out of range", and skipping the latter would drop a
1631                // position from the total silently — the same class of bug as
1632                // clamping it (#1863).
1633                let Some(cost) = pos.cost.as_ref() else {
1634                    continue;
1635                };
1636                let overflow = || OverflowError {
1637                    currency: cost.currency.clone(),
1638                };
1639                let book = cost.total_cost(pos.units.number).ok_or_else(overflow)?;
1640                let slot = totals.entry(book.currency.clone()).or_default();
1641                *slot = slot.checked_add(book.number).ok_or_else(overflow)?;
1642            }
1643        }
1644
1645        Ok(totals)
1646    }
1647
1648    /// Add a position to the inventory.
1649    ///
1650    /// For positions without cost, this merges with existing positions
1651    /// of the same currency using O(1) `HashMap` lookup.
1652    ///
1653    /// For positions with cost, this adds as a new lot (O(1)).
1654    /// Lot aggregation for display purposes is handled separately at output time
1655    /// (e.g., in the query result formatter).
1656    ///
1657    /// # TLA+ Specification
1658    ///
1659    /// Implements `AddAmount` action from `Conservation.tla`:
1660    /// - Invariant: `inventory + totalReduced = totalAdded`
1661    /// - After add: `totalAdded' = totalAdded + amount`
1662    ///
1663    /// See: `spec/tla/Conservation.tla`
1664    ///
1665    /// # Errors
1666    ///
1667    /// [`OverflowError`] when the running total for this currency leaves
1668    /// `rust_decimal`'s ~±7.9e28 range. The inventory is left UNCHANGED — the
1669    /// units cache is only committed once the merge is known to fit, so a
1670    /// caller that reports the error and moves on does not carry a
1671    /// half-applied position (#1863).
1672    pub fn add(&mut self, position: Position) -> Result<(), OverflowError> {
1673        if position.is_empty() {
1674            return Ok(());
1675        }
1676
1677        let overflow = || OverflowError {
1678            currency: position.units.currency.clone(),
1679        };
1680
1681        // Compute both running totals BEFORE mutating anything, so an overflow
1682        // leaves the inventory untouched rather than half-updated.
1683        let cached = self
1684            .units_cache
1685            .get(&position.units.currency)
1686            .map(|s| s.total)
1687            .unwrap_or_default();
1688        // Python `decimal` scale semantics, not raw `checked_add` — see
1689        // `crate::decimal::add_python_scale`. `rust_decimal` returns the other
1690        // operand untouched when one side is zero, so a running total that
1691        // passes through zero drops its scale and everything added after it
1692        // renders one scale narrower. That made a coalesced balance
1693        // ORDER-DEPENDENT: the same postings in a different order produced
1694        // `1` or `1.00` for the same money.
1695        let new_cached = crate::decimal::checked_add_python_scale(cached, position.units.number)
1696            .ok_or_else(overflow)?;
1697
1698        let merge_idx = position
1699            .cost
1700            .is_none()
1701            .then(|| {
1702                self.units_cache
1703                    .get(&position.units.currency)
1704                    .and_then(|s| s.simple_slot)
1705            })
1706            .flatten();
1707        let merged_units = merge_idx
1708            .map(|idx| {
1709                // Same rule as the units cache above — these two must agree,
1710                // or `units()` and the position itself report different scales
1711                // for the same currency.
1712                crate::decimal::checked_add_python_scale(
1713                    self.positions[idx].units.number,
1714                    position.units.number,
1715                )
1716                .ok_or_else(overflow)
1717            })
1718            .transpose()?;
1719
1720        // Bucket changes, worked out before touching the cache so the whole
1721        // update lands in ONE lookup below. A cost-less merge can flip the
1722        // lot's sign (adding -8 to a +3 lot), which moves it between buckets;
1723        // `is_sign_positive` answers true for zero, matching the predicate
1724        // `is_reduced_by` uses.
1725        let vacated = merge_idx.map(|idx| {
1726            let lot = &self.positions[idx];
1727            (lot.cost.is_some(), lot.units.number.is_sign_positive())
1728        });
1729        let occupied = (
1730            position.cost.is_some(),
1731            merged_units
1732                .unwrap_or(position.units.number)
1733                .is_sign_positive(),
1734        );
1735
1736        // ONE mutable lookup for the total AND the counts. `add` runs once per
1737        // posting, and the units cache is keyed by an interned string whose
1738        // `Hash` walks its bytes — this used to be a `get` plus an `insert`,
1739        // and hanging the counts off a second map made it three hashes per
1740        // posting, which measured as a regression on ledgers that book no
1741        // cost specs. `get_mut` first so only a currency's first lot pays for
1742        // an owned key.
1743        if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
1744            stats.total = new_cached;
1745            if let Some((had_cost, was_positive)) = vacated {
1746                stats.counts.bump(had_cost, was_positive, -1);
1747            }
1748            stats.counts.bump(occupied.0, occupied.1, 1);
1749        } else {
1750            // No entry yet means no lot of this currency has ever been added,
1751            // so there is nothing to vacate: `merge_idx` came from
1752            // `simple_index`, which only names a lot that `add` already
1753            // counted.
1754            debug_assert!(
1755                vacated.is_none(),
1756                "merging into a lot whose currency has no cached entry",
1757            );
1758            let mut counts = SignCounts::default();
1759            counts.bump(occupied.0, occupied.1, 1);
1760            self.units_cache.insert(
1761                position.units.currency.clone(),
1762                CurrencyStats {
1763                    total: new_cached,
1764                    counts,
1765                    simple_slot: None,
1766                },
1767            );
1768        }
1769
1770        // For positions without cost, use index for O(1) lookup
1771        if position.cost.is_none() {
1772            if let Some(idx) = merge_idx {
1773                // Merge with existing position
1774                debug_assert!(self.positions[idx].cost.is_none());
1775                self.positions[idx].units.number =
1776                    merged_units.expect("merged_units is Some whenever merge_idx is");
1777                return Ok(());
1778            }
1779            // No existing position - add new one and index it
1780            // `push_slot`, not `len()`: with tombstones present the live
1781            // count is not the slot the lot lands in, and `simple_index`
1782            // stores slots.
1783            let currency = position.units.currency.clone();
1784            let idx = self.positions.push_slot(position);
1785            // The stats entry exists: the totals above were written before
1786            // this point for every currency that reaches here.
1787            self.units_cache.entry(currency).or_default().simple_slot = Some(idx);
1788            return Ok(());
1789        }
1790
1791        // For positions with cost, just add as a new lot.
1792        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
1793        // Lot aggregation for display purposes is handled separately in query output.
1794        let key = cost_key(&position);
1795        // Every position, not only cost-bearing ones: an empty cost spec
1796        // matches a cost-less lot (`matches_cost_spec`: `(None, true)`), so
1797        // ordered selection can drain one, and an index that omitted them
1798        // picked a different lot than the scan.
1799        let ordering = position.units.currency.clone();
1800        let slot = self.positions.push_slot(position);
1801        if let Some(key) = key {
1802            self.cost_index.entry(key).or_default().push(slot);
1803        }
1804        self.ordered_index_insert(&ordering, slot);
1805        Ok(())
1806    }
1807
1808    /// Adjust `sign_index` for the position currently at `idx` by `delta`.
1809    ///
1810    /// Called with `-1` before changing or removing a lot and `+1` after, so
1811    /// a sign flip lands in the right bucket.
1812    /// Drop `idx` from [`Self::cost_index`]. Called wherever a lot is
1813    /// tombstoned, since the slot stays valid but the lot is gone.
1814    pub(super) fn cost_index_remove(&mut self, idx: usize) {
1815        let Some(position) = self.positions.get(idx) else {
1816            return;
1817        };
1818        // Only pay for the ordered index when one has been built: this runs on
1819        // every drained lot, and cloning the currency to probe a map that is
1820        // not there is pure overhead for a ledger that never reduces with an
1821        // under-specified spec.
1822        let ordered = self
1823            .ordered_index
1824            .is_some()
1825            .then(|| position.units.currency.clone());
1826        if let Some(key) = cost_key(position)
1827            && let Some(slots) = self.cost_index.get_mut(&key)
1828        {
1829            slots.retain(|slot| *slot != idx);
1830            if slots.is_empty() {
1831                self.cost_index.remove(&key);
1832            }
1833        }
1834        // The list is ordered, so find the entry rather than scanning for it:
1835        // a FIFO account drains its oldest lot over and over, and `retain`
1836        // walked every lot each time.
1837        let Some(currency) = ordered else {
1838            return;
1839        };
1840        let Some(index) = self.ordered_index.as_mut() else {
1841            return;
1842        };
1843        if let Some(slots) = index.by_currency.get_mut(&currency) {
1844            // Linear here rather than a binary search: `order_key` needs
1845            // `&self.positions`, which is already borrowed through `index`.
1846            // Removal is off the hot path — the walk is what this index exists
1847            // to speed up — and it keeps the ordering rule in one place.
1848            let at = slots.iter().position(|&existing| existing == idx);
1849            if let Some(at) = at {
1850                slots.remove(at);
1851            }
1852            if slots.is_empty() {
1853                index.by_currency.remove(&currency);
1854            }
1855        }
1856    }
1857
1858    /// Place `slot` under `currency`, keeping the list in (date, slot) order.
1859    ///
1860    /// Ledgers book in date order, so the new lot almost always belongs at the
1861    /// end and the search settles immediately; the binary search is what keeps
1862    /// an out-of-order lot correct rather than fast.
1863    fn ordered_index_insert(&mut self, currency: &crate::Currency, slot: usize) {
1864        // Maintain only an index that has been built. A ledger whose
1865        // reductions all resolve through `cost_index` never builds one and so
1866        // never pays for it: maintaining it from every `add` unconditionally
1867        // cost 6% on the `investment` shape, which never reads it.
1868        if !matches!(self.positions, PositionStore::Owned(_)) {
1869            return;
1870        }
1871        let Some(mut index) = self.ordered_index.take() else {
1872            return;
1873        };
1874        let order = index.order;
1875        let key = (self.order_key(order, slot), slot);
1876        // The index is OUT of `self` for the search, so the binary search can
1877        // read `self.positions` for each probe. Materializing the keys instead
1878        // — the obvious way around the borrow — makes every `add` walk the
1879        // whole currency, which is the quadratic this index exists to remove.
1880        let entry = index.by_currency.entry(currency.clone()).or_default();
1881        let at =
1882            entry.partition_point(|&existing| (self.order_key(order, existing), existing) < key);
1883        entry.insert(at, slot);
1884        self.ordered_index = Some(index);
1885    }
1886
1887    /// Populate `ordered_index` from the current lots — all of them, cost-less
1888    /// included, because an empty cost spec selects those too.
1889    ///
1890    /// Called the first time an ordered booking method reduces against this
1891    /// inventory, then kept current incrementally. Ordering matches what
1892    /// `plan_ordered` produced when it sorted per call: date ascending, slot
1893    /// ascending within a date.
1894    /// The value `order` sorts `slot` by. Ties fall through to the slot
1895    /// number, which is what makes both orderings match the stable sorts they
1896    /// replace: `sort_by_key(date)` and `sort_by_key(Reverse(cost))` both left
1897    /// equal keys in ascending slot order.
1898    fn order_key(
1899        &self,
1900        order: LotOrder,
1901        slot: usize,
1902    ) -> (Option<Decimal>, Option<crate::NaiveDate>) {
1903        let cost = self.positions.get(slot).and_then(|p| p.cost.as_ref());
1904        match order {
1905            LotOrder::Date => (None, cost.and_then(|c| c.date)),
1906            // Negated rather than reversed so the tuple still sorts ascending
1907            // and the slot tiebreak keeps its direction.
1908            //
1909            // A cost-less lot counts as zero rather than as `None`. `None`
1910            // sorts BEFORE `Some`, which would put cost-less lots at the front
1911            // of a highest-cost-first walk — the opposite of where the
1912            // `map_or(Decimal::ZERO, ..)` this replaces put them. An empty cost
1913            // spec matches a cost-less position, so HIFO can reach one.
1914            LotOrder::CostDescending => (Some(-cost.map_or(Decimal::ZERO, |c| c.number)), None),
1915        }
1916    }
1917
1918    pub(super) fn build_ordered_index(&mut self, order: LotOrder) {
1919        if !matches!(self.positions, PositionStore::Owned(_)) {
1920            return;
1921        }
1922        let mut by_currency: FxHashMap<crate::Currency, Vec<usize>> = FxHashMap::default();
1923        for (idx, pos) in self.positions.iter_slots() {
1924            by_currency
1925                .entry(pos.units.currency.clone())
1926                .or_default()
1927                .push(idx);
1928        }
1929        for slots in by_currency.values_mut() {
1930            slots.sort_by_key(|&idx| self.order_key(order, idx));
1931        }
1932        self.ordered_index = Some(Box::new(OrderedIndex { order, by_currency }));
1933    }
1934
1935    /// Every slot of `currency` in FIFO order, or `None` when the index cannot
1936    /// answer and the caller must scan.
1937    ///
1938    /// Cost-less slots included — see the field's own note on why.
1939    fn ordered_candidates(&self, currency: &crate::Currency, order: LotOrder) -> Option<&[usize]> {
1940        let index = self.ordered_index.as_ref()?;
1941        // A different ordering answers a different question; scanning is the
1942        // only correct fallback until something rebuilds it.
1943        if index.order != order {
1944            return None;
1945        }
1946        Some(
1947            index
1948                .by_currency
1949                .get(currency)
1950                .map_or(&[][..], Vec::as_slice),
1951        )
1952    }
1953
1954    /// Slots that could satisfy `spec` for `units`, or `None` when the spec
1955    /// names no per-unit cost and therefore every lot is a candidate.
1956    ///
1957    /// Returned ascending so callers see the same order a scan would.
1958    fn cost_candidates(&self, units: &Amount, spec: &CostSpec) -> Option<Vec<usize>> {
1959        // An empty index means it was never built for this inventory — a
1960        // shared snapshot, or one that has not been rebuilt since. Scanning is
1961        // always correct, and answering from an index that is missing entries
1962        // is NOT: the lot would never reach the predicate. Falling back keeps
1963        // the only failure mode the harmless one.
1964        if self.cost_index.is_empty() {
1965            return None;
1966        }
1967        let number = spec.number.and_then(|n| n.per_unit())?;
1968        let currency = spec.currency.clone()?;
1969        let mut slots = self
1970            .cost_index
1971            .get(&(units.currency.clone(), number, currency))
1972            .cloned()
1973            .unwrap_or_default()
1974            .to_vec();
1975        slots.sort_unstable();
1976        Some(slots)
1977    }
1978
1979    pub(super) fn sign_index_bump(&mut self, idx: usize, delta: i32) {
1980        // All three call sites pass an index they just read or wrote, so this
1981        // is defensive only. Returning rather than panicking keeps a future
1982        // caller's off-by-one out of the panic path; the counts then disagree
1983        // with a scan, which `is_reduced_by`'s assertion reports in debug.
1984        debug_assert!(
1985            idx < self.positions.slot_count(),
1986            "sign_index_bump called with out-of-range index {idx}",
1987        );
1988        let Some(position) = self.positions.get(idx) else {
1989            return;
1990        };
1991        // Read the two bits the bucket depends on and drop the borrow. Cloning
1992        // the `Position` here instead — which is what the obvious version does
1993        // to satisfy the borrow checker — costs an `Arc` bump per currency plus
1994        // the lot's label on EVERY add, and this runs on the hot path.
1995        let has_cost = position.cost.is_some();
1996        let is_positive = position.units.number.is_sign_positive();
1997        if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
1998            stats.counts.bump(has_cost, is_positive, delta);
1999        }
2000        // No entry means no lots of this currency have been counted yet, which
2001        // only happens before `add` records the total. `add` inserts the entry
2002        // before calling this, and the rebuild path fills both together.
2003    }
2004
2005    /// Reduce positions from the inventory using the specified booking method.
2006    ///
2007    /// # Arguments
2008    ///
2009    /// * `units` - The units to reduce (negative for selling)
2010    /// * `cost_spec` - Optional cost specification for matching lots
2011    /// * `method` - The booking method to use
2012    ///
2013    /// # Returns
2014    ///
2015    /// Returns a `BookingResult` with the matched positions and cost basis,
2016    /// or a `BookingError` if the reduction cannot be performed.
2017    ///
2018    /// # TLA+ Specification
2019    ///
2020    /// Implements `ReduceAmount` action from `Conservation.tla`:
2021    /// - Invariant: `inventory + totalReduced = totalAdded`
2022    /// - After reduce: `totalReduced' = totalReduced + amount`
2023    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
2024    ///
2025    /// Lot selection follows these TLA+ specs based on `method`:
2026    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
2027    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
2028    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
2029    ///
2030    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
2031    pub fn reduce(
2032        &mut self,
2033        units: &Amount,
2034        cost_spec: Option<&CostSpec>,
2035        method: BookingMethod,
2036    ) -> Result<BookingResult, BookingError> {
2037        let spec = cost_spec.cloned().unwrap_or_default();
2038
2039        // Force a uniquely-owned positions Vector before any reduction mutates
2040        // it. `self.positions` MAY be structurally shared — BQL snapshots build
2041        // `Shared` stores via `Inventory::new_shared` — and every reduction
2042        // method below mutates it in place (via `IndexMut` / `retain`).
2043        //
2044        // The sharing comes from BQL, not from booking. Since #2056 the store
2045        // is a hybrid and `PositionStore::default()` is `Owned(Vec)`, so the
2046        // booking engine's inventories are owned and any copy of one is a
2047        // DEEP O(lots) copy rather than an imbl O(1) one. This comment
2048        // asserted the opposite until #2061, and that wrong claim is a good
2049        // part of why the copy went unexamined for so long — `Position::clone`
2050        // was growing 104x for 10x the input on the `investment` profiling
2051        // shape.
2052        //
2053        // `BookingEngine::book` no longer takes such a copy per transaction —
2054        // it previews through `try_reduce`, which computes from `&self` via
2055        // the `plan_*` halves in `booking.rs`, and copies only for an account
2056        // with more than one reducing posting in the same transaction.
2057        //
2058        // Mutating a SHARED imbl `Vector` in place drives
2059        // `imbl-sized-chunks`' copy-on-write into a use-after-free of the
2060        // interned `Arc<str>` inside `Position` — heap corruption / SIGSEGV on
2061        // large ledgers with many lot reductions (found by the rich-workload
2062        // profiler). Rebuilding from cloned positions restores a refcount-1
2063        // Vector with correct `Arc` refcounting, so in-place mutation below has
2064        // no shared chunk to corrupt.
2065        self.positions.make_owned();
2066
2067        // Compaction does NOT run here. It renumbers slots, which would
2068        // invalidate an open undo log — and `apply` keeps one across the whole
2069        // transaction. `compact_if_sparse` is called by the engine after a
2070        // transaction commits, which is the only moment no slot index is held
2071        // and no rollback can still be required.
2072        // Compact here UNLESS a transaction is in flight. Compaction renumbers
2073        // slots and an open undo log refers to them, so `apply` defers it to
2074        // commit — but every other caller reduces without a log, and tying
2075        // compaction to `apply` alone would leave those inventories growing a
2076        // dead slot per closed lot forever.
2077        //
2078        // That is not hypothetical: the Late validator keeps its own
2079        // inventories across transactions and calls `reduce` directly, so it
2080        // would have accumulated one tombstone per sale for the life of the
2081        // ledger and scanned all of them on every reduction.
2082        //
2083        // No assertion about the tombstone ratio: a `{*}` merge legitimately
2084        // tombstones every matched lot and pushes one, so dead slots CAN
2085        // outnumber live ones mid-transaction.
2086        if !self.undo_open {
2087            self.compact_if_sparse();
2088        }
2089
2090        // Ordered selection walks lots in date order, so give it the index
2091        // that holds them that way — built here, on the first reduction that
2092        // will actually read it, and maintained incrementally afterwards. A
2093        // STRICT account resolves through `cost_index` instead and never
2094        // reaches this, which is why the build is gated rather than
2095        // unconditional (#2083).
2096        // Which ordering this account's method consumes, if any. STRICT
2097        // resolves through `cost_index` instead and never reaches the walk, so
2098        // it builds nothing.
2099        let wanted_order = match method {
2100            BookingMethod::Fifo | BookingMethod::Lifo => Some(LotOrder::Date),
2101            BookingMethod::Hifo => Some(LotOrder::CostDescending),
2102            _ => None,
2103        };
2104        if let Some(order) = wanted_order
2105            && self.ordered_index.as_ref().is_none_or(|i| i.order != order)
2106        {
2107            self.build_ordered_index(order);
2108        }
2109
2110        // {*} merge operator: merge all lots into a single weighted-average-cost
2111        // lot before reducing, regardless of the account's booking method.
2112        if spec.merge {
2113            return self.reduce_merge(units);
2114        }
2115
2116        match method {
2117            BookingMethod::Strict => self.reduce_strict(units, &spec),
2118            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
2119            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
2120            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
2121            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
2122            BookingMethod::Average => self.reduce_average(units),
2123            BookingMethod::None => self.reduce_none(units),
2124        }
2125    }
2126
2127    /// Remove all empty positions.
2128    pub fn compact(&mut self) {
2129        self.positions.retain(|p| !p.is_empty());
2130        self.rebuild_index();
2131    }
2132
2133    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
2134    ///
2135    /// Called after operations that may invalidate them (`compact`'s retain) and
2136    /// on deserialization, which is what [`CacheSource`] distinguishes.
2137    fn rebuild_index(&mut self) {
2138        // Internal positions came through `add`, which already rejected any
2139        // sum that would overflow, so this cannot fail. Asserted rather than
2140        // ignored: a failure here would mean `add`'s check had a hole.
2141        // Call FIRST, assert on the result. Putting the call inside
2142        // `debug_assert!` compiles the rebuild itself out of release builds,
2143        // so `compact` would have left the caches stale — caught by clippy's
2144        // `debug_assert_with_mut_call`.
2145        let rebuilt = self.try_rebuild_index_from(CacheSource::Internal);
2146        debug_assert!(
2147            rebuilt.is_ok(),
2148            "internal positions summed past the Decimal range; `add` should \
2149             have rejected them",
2150        );
2151    }
2152
2153    fn try_rebuild_index_from(&mut self, source: CacheSource) -> Result<(), OverflowError> {
2154        self.units_cache.clear();
2155        self.cost_index.clear();
2156        // Preserve whether the ordered index has been BUILT, rather than
2157        // building it here. A rebuild happens on compaction and on rollback,
2158        // neither of which means ordered selection is in use — repopulating
2159        // unconditionally handed the index (and its maintenance cost) to every
2160        // ledger, including the ones whose reductions all resolve through
2161        // `cost_index`.
2162        let ordered_was_built = self.ordered_index.as_ref().map(|i| i.order);
2163        self.ordered_index = None;
2164
2165        // The cost index is for BOOKING, and only the owned backing books.
2166        //
2167        // Not a micro-optimization: `Inventory` derives `Clone` and BQL clones
2168        // a shared snapshot ONCE PER OUTPUT ROW (`running_balance.clone()` in
2169        // the executor). This map holds roughly an entry per distinct cost, so
2170        // building it for shared inventories would put O(lots) back into every
2171        // per-row clone — the O(rows x lots) blow-up that #1086 is about and
2172        // that the shared backing exists to avoid. Snapshots keep an empty map
2173        // and clone it for free.
2174        let index_costs = matches!(self.positions, PositionStore::Owned(_));
2175
2176        for (idx, pos) in self.positions.iter_slots() {
2177            if index_costs {
2178                if let Some(key) = cost_key(pos) {
2179                    self.cost_index.entry(key).or_default().push(idx);
2180                }
2181                if let Some(order) = ordered_was_built {
2182                    self.ordered_index
2183                        .get_or_insert_with(|| {
2184                            Box::new(OrderedIndex {
2185                                order,
2186                                by_currency: FxHashMap::default(),
2187                            })
2188                        })
2189                        .by_currency
2190                        .entry(pos.units.currency.clone())
2191                        .or_default()
2192                        .push(idx);
2193                }
2194            }
2195            // Update units cache for all positions. Checked, not `+=`:
2196            // `Decimal`'s `+` panics on overflow, and this runs over payloads.
2197            //
2198            // Must apply the SAME Python-scale rule as `add`, not a raw
2199            // `checked_add`. `units_cache` is `#[serde(skip)]`, so this is the
2200            // path that reconstructs it after a round-trip; if the two
2201            // disagreed, an inventory built incrementally and the same
2202            // inventory deserialized would report different scales for the
2203            // same money — measured at `1.00` built vs `1` rebuilt, across
2204            // three cost lots summing through zero. Pinned by
2205            // `a_round_trip_reports_the_same_scale_as_incremental_adds`.
2206            let slot = self
2207                .units_cache
2208                .entry(pos.units.currency.clone())
2209                .or_default();
2210            slot.counts
2211                .bump(pos.cost.is_some(), pos.units.number.is_sign_positive(), 1);
2212            slot.total = crate::decimal::checked_add_python_scale(slot.total, pos.units.number)
2213                .ok_or_else(|| OverflowError {
2214                    currency: pos.units.currency.clone(),
2215                })?;
2216
2217            // Record the cost-less lot only for positions without cost
2218            if pos.cost.is_none() {
2219                debug_assert!(
2220                    source == CacheSource::Untrusted
2221                        || self
2222                            .units_cache
2223                            .get(&pos.units.currency)
2224                            .is_none_or(|s| s.simple_slot.is_none()),
2225                    "Invariant violated: multiple simple positions for currency {}",
2226                    pos.units.currency
2227                );
2228                // Last-wins on a duplicate, matching the pre-existing behavior
2229                // of this write. `units_cache` sums every position either way,
2230                // so the total stays right; only which lot a later cost-less
2231                // `add` merges into is affected.
2232                self.units_cache
2233                    .entry(pos.units.currency.clone())
2234                    .or_default()
2235                    .simple_slot = Some(idx);
2236            }
2237        }
2238
2239        // The walk above pushed in slot order; ordered selection wants date
2240        // order with slot as the tiebreak. `sort_by_key` is stable, so the
2241        // slot order already there survives — the same two-level order
2242        // `plan_ordered` produced when it sorted per call.
2243        if let Some(order) = ordered_was_built {
2244            let keys: Vec<(crate::Currency, Vec<usize>)> = self
2245                .ordered_index
2246                .as_ref()
2247                .map(|i| {
2248                    i.by_currency
2249                        .iter()
2250                        .map(|(c, slots)| (c.clone(), slots.clone()))
2251                        .collect()
2252                })
2253                .unwrap_or_default();
2254            for (currency, mut slots) in keys {
2255                slots.sort_by_key(|&idx| self.order_key(order, idx));
2256                if let Some(index) = self.ordered_index.as_mut() {
2257                    index.by_currency.insert(currency, slots);
2258                }
2259            }
2260        }
2261        Ok(())
2262    }
2263
2264    /// Merge this inventory with another.
2265    ///
2266    /// # Errors
2267    ///
2268    /// [`OverflowError`] when a merged running total leaves `rust_decimal`'s
2269    /// range. `self` keeps the positions merged before the failure.
2270    pub fn merge(&mut self, other: &Self) -> Result<(), OverflowError> {
2271        for pos in other.positions.iter() {
2272            self.add(pos.clone())?;
2273        }
2274        Ok(())
2275    }
2276
2277    /// Convert inventory to cost basis.
2278    ///
2279    /// Returns a new inventory where all positions are converted to their
2280    /// cost basis. Positions without cost are returned as-is.
2281    ///
2282    /// # Errors
2283    ///
2284    /// [`OverflowError`] when a `units × cost` product, or the running total
2285    /// of those products, leaves `rust_decimal`'s range. Note this can fire on
2286    /// inputs far below the ceiling — the product overflows when neither
2287    /// operand does.
2288    pub fn at_cost(&self) -> Result<Self, OverflowError> {
2289        let mut result = Self::new();
2290
2291        for pos in self.positions.iter() {
2292            if pos.is_empty() {
2293                continue;
2294            }
2295
2296            if let Some(cost) = &pos.cost {
2297                // Convert to cost basis
2298                let total =
2299                    pos.units
2300                        .number
2301                        .checked_mul(cost.number)
2302                        .ok_or_else(|| OverflowError {
2303                            currency: cost.currency.clone(),
2304                        })?;
2305                result.add(Position::simple(Amount::new(total, &cost.currency)))?;
2306            } else {
2307                // No cost, keep as-is
2308                result.add(pos.clone())?;
2309            }
2310        }
2311
2312        Ok(result)
2313    }
2314
2315    /// Convert inventory to units only.
2316    ///
2317    /// Returns a new inventory where all positions have their cost removed,
2318    /// effectively aggregating by currency only.
2319    ///
2320    /// # Errors
2321    ///
2322    /// [`OverflowError`] when stripping costs merges lots whose combined units
2323    /// leave `rust_decimal`'s range.
2324    pub fn at_units(&self) -> Result<Self, OverflowError> {
2325        let mut result = Self::new();
2326
2327        for pos in self.positions.iter() {
2328            if pos.is_empty() {
2329                continue;
2330            }
2331
2332            // Strip cost, keep only units
2333            result.add(Position::simple(pos.units.clone()))?;
2334        }
2335
2336        Ok(result)
2337    }
2338}
2339
2340/// Sum the units of `currency` across `account` AND all of its sub-accounts,
2341/// over a map of per-account inventories.
2342///
2343/// Beancount's `balance Assets:Bank` assertion — and the pad math that targets
2344/// it — includes `Assets:Bank:Checking`, `Assets:Bank:Savings`, etc. (verified
2345/// against `bean-check`: an assertion on a parent passes when the balance is held
2346/// in a sub-account). Sub-account membership uses [`is_subaccount_or_equal`], so
2347/// the segment-boundary rule (`Assets:BankAlias` does NOT match `Assets:Bank`)
2348/// is shared.
2349///
2350/// This is the single source for that sum, used by both the booking pad engine
2351/// and the Late balance validator. They previously computed the pad/assertion
2352/// difference differently — booking summed only the leaf account
2353/// (`Inventory::units`) while the validator summed sub-accounts — so a pad
2354/// targeting a non-leaf account inserted the wrong synthetic amount.
2355///
2356/// Returns `None` when the sum leaves `rust_decimal`'s range (`Decimal`'s
2357/// `Sum` impl panics rather than wrapping). Both callers surface that as a
2358/// diagnostic on the assertion/pad rather than asserting against a clamped
2359/// total (#1863).
2360pub fn sum_account_and_subaccounts<'a, I>(
2361    inventories: I,
2362    account: &str,
2363    currency: &Currency,
2364) -> Option<Decimal>
2365where
2366    I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
2367{
2368    inventories
2369        .into_iter()
2370        .filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
2371        .try_fold(Decimal::ZERO, |acc, (_, inv)| {
2372            acc.checked_add(inv.units(currency))
2373        })
2374}
2375
2376impl fmt::Display for Inventory {
2377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2378        if self.is_empty() {
2379            return write!(f, "(empty)");
2380        }
2381
2382        // Sort positions alphabetically by currency, then by cost for consistency
2383        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
2384        non_empty.sort_by(|a, b| {
2385            // First by currency
2386            let cmp = a.units.currency.cmp(&b.units.currency);
2387            if cmp != std::cmp::Ordering::Equal {
2388                return cmp;
2389            }
2390            // Then by cost (if present)
2391            match (&a.cost, &b.cost) {
2392                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
2393                (Some(_), None) => std::cmp::Ordering::Greater,
2394                (None, Some(_)) => std::cmp::Ordering::Less,
2395                (None, None) => std::cmp::Ordering::Equal,
2396            }
2397        });
2398
2399        for (i, pos) in non_empty.iter().enumerate() {
2400            if i > 0 {
2401                write!(f, ", ")?;
2402            }
2403            write!(f, "{pos}")?;
2404        }
2405        Ok(())
2406    }
2407}
2408
2409impl Inventory {
2410    /// Build an inventory from positions.
2411    ///
2412    /// Replaces the former `FromIterator<Position>` impl, which was removed
2413    /// deliberately: `from_iter` cannot report failure, so it had to swallow
2414    /// the overflow from [`Self::add`] and hand back an inventory holding a
2415    /// wrong total with nothing to indicate it (#1863). A `collect()` that can
2416    /// silently lie is worse than no `collect()`.
2417    ///
2418    /// # Errors
2419    ///
2420    /// [`OverflowError`] when a running total leaves `rust_decimal`'s range.
2421    pub fn try_from_positions<I>(iter: I) -> Result<Self, OverflowError>
2422    where
2423        I: IntoIterator<Item = Position>,
2424    {
2425        let mut inv = Self::new();
2426        for pos in iter {
2427            inv.add(pos)?;
2428        }
2429        Ok(inv)
2430    }
2431}
2432
2433#[cfg(test)]
2434mod tests {
2435
2436    /// A deserialized inventory must not be reported as having headroom it
2437    /// does not have.
2438    ///
2439    /// The caches are `#[serde(skip)]`, so a round-trip once left `positions`
2440    /// populated and both caches empty. Deserialization now rebuilds them, so
2441    /// this passes because the cache is CORRECT rather than because
2442    /// `add_headroom_for` refuses to read an empty one. Both are checked: the
2443    /// defensive refusal stays as the second line of defense for any other way
2444    /// an inventory might reach that state (review catch on #1898).
2445    /// `new_shared` must actually produce the shared backing, and a serde
2446    /// round-trip must land back in `Owned`.
2447    ///
2448    /// Both are load-bearing and neither is visible from the public API: the
2449    /// backing is a private enum, so nothing outside this module can observe
2450    /// which one an inventory holds. Without this test, `new_shared` could
2451    /// quietly return the contiguous backing and the only symptom would be
2452    /// BQL's JOURNAL memory going from 31 MB back to 395 MB on a large
2453    /// ledger — a regression no unit test would catch. Copilot's catch on
2454    /// #2056.
2455    #[test]
2456    fn new_shared_is_shared_and_a_round_trip_is_owned() {
2457        let mut shared = Inventory::new_shared();
2458        assert!(
2459            matches!(shared.positions, PositionStore::Shared(_)),
2460            "new_shared must use the structurally-shared backing",
2461        );
2462
2463        // Adding must not silently convert it — the per-row snapshot in BQL
2464        // adds to this inventory between every clone.
2465        shared
2466            .add(Position::simple(Amount::new(dec!(5), "USD")))
2467            .expect("fits");
2468        assert!(
2469            matches!(shared.positions, PositionStore::Shared(_)),
2470            "add must keep the shared backing; converting here would restore \
2471             the O(rows x lots) blow-up #1086 is about",
2472        );
2473
2474        // ...but a reduction does convert, deliberately: it mutates heavily
2475        // and wants contiguous storage.
2476        let mut reduced = Inventory::new_shared();
2477        reduced
2478            .add(Position::simple(Amount::new(dec!(5), "USD")))
2479            .expect("fits");
2480        let _ = reduced.reduce(&Amount::new(dec!(-2), "USD"), None, BookingMethod::None);
2481        assert!(
2482            matches!(reduced.positions, PositionStore::Owned(_)),
2483            "reduce must switch to the contiguous backing",
2484        );
2485
2486        // The default constructor is contiguous.
2487        assert!(matches!(
2488            Inventory::new().positions,
2489            PositionStore::Owned(_)
2490        ));
2491
2492        // Serde carries a plain sequence and lands in `Owned`.
2493        let json = serde_json::to_string(&shared).expect("serializes");
2494        let back: Inventory = serde_json::from_str(&json).expect("deserializes");
2495        assert!(
2496            matches!(back.positions, PositionStore::Owned(_)),
2497            "a round-trip lands in the contiguous backing",
2498        );
2499        assert_eq!(back.units("USD"), dec!(5), "and preserves the positions");
2500    }
2501
2502    #[test]
2503    fn a_deserialized_inventory_refuses_to_claim_headroom() {
2504        let mut inv = Inventory::new();
2505        inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2506            .expect("one MAX position fits");
2507        assert!(!inv.add_headroom_for("USD", Decimal::ONE));
2508
2509        let round_tripped: Inventory =
2510            serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2511                .expect("deserialize");
2512
2513        assert!(
2514            !round_tripped.positions.is_empty(),
2515            "the positions survive the round-trip"
2516        );
2517        assert!(
2518            !round_tripped.units_cache.is_empty(),
2519            "and so do the caches now — deserialization rebuilds them"
2520        );
2521
2522        assert!(
2523            !round_tripped.add_headroom_for("USD", Decimal::ONE),
2524            "the inventory still holds Decimal::MAX"
2525        );
2526    }
2527
2528    /// A payload the type could not have produced must not panic us.
2529    ///
2530    /// Two cost-less lots for one currency violate the invariant
2531    /// `rebuild_index` asserts. That assert is a worthwhile internal-bug
2532    /// tripwire, but rebuilding on deserialization put it in reach of INPUT:
2533    /// this exact document panicked a debug build with "Invariant violated:
2534    /// multiple simple positions for currency USD". Caught reviewing the
2535    /// rebuild change, not present before it.
2536    ///
2537    /// Behavior matches what the plain derive did — the total is the sum, the
2538    /// lots are preserved — so nothing about malformed input changed except
2539    /// that the caches are now correct for it.
2540    #[test]
2541    fn a_payload_violating_the_lot_invariant_does_not_panic() {
2542        let json = r#"{"positions":[
2543            {"units":{"number":"100","currency":"USD"},"cost":null},
2544            {"units":{"number":"5","currency":"USD"},"cost":null}]}"#;
2545        let inv: Inventory = serde_json::from_str(json).expect("malformed input still loads");
2546        assert_eq!(inv.units("USD"), dec!(105), "the total sums every lot");
2547        assert_eq!(
2548            inv.positions().count(),
2549            2,
2550            "the lots are preserved as given"
2551        );
2552    }
2553
2554    /// A payload whose positions sum past the `Decimal` range is an ERROR,
2555    /// not a panic.
2556    ///
2557    /// Rebuilding the caches sums each currency's positions, and the rebuild
2558    /// used `+=`, which panics on `Decimal` overflow. Running it on
2559    /// deserialization put that inside `Deserialize`: two `Decimal::MAX` USD
2560    /// lots aborted with "Addition overflowed" instead of returning a serde
2561    /// error — a denial of service on any embedder deserializing untrusted input. Review
2562    /// catch on the rebuild change; the deep review that found the
2563    /// `debug_assert` panic missed this second one.
2564    ///
2565    /// Two lots are needed, and the first must carry a cost: a second cost-less
2566    /// lot for the same currency would be a different (also-tested) malformed
2567    /// shape, and the sum is what is being exercised here.
2568    #[test]
2569    fn a_payload_that_overflows_the_total_is_an_error_not_a_panic() {
2570        let max = Decimal::MAX.to_string();
2571        let json = format!(
2572            r#"{{"positions":[
2573                {{"units":{{"number":"{max}","currency":"USD"}},
2574                  "cost":{{"number":"1","currency":"EUR","date":null,"label":null}}}},
2575                {{"units":{{"number":"{max}","currency":"USD"}},"cost":null}}]}}"#
2576        );
2577        let err = serde_json::from_str::<Inventory>(&json)
2578            .expect_err("a total past the Decimal range cannot be represented");
2579        // `OverflowError`'s own wording, which serde surfaces verbatim — so
2580        // this also pins that the error reaching the caller is the domain one
2581        // rather than a generic "invalid value".
2582        assert!(
2583            err.to_string().contains("exceeds the representable range"),
2584            "expected the USD overflow error, got: {err}",
2585        );
2586    }
2587
2588    /// `positions` stays REQUIRED.
2589    ///
2590    /// The derive this replaced made it so, and routing deserialization through
2591    /// a wire struct is exactly the kind of change that silently relaxes it —
2592    /// a stray `#[serde(default)]` turns a malformed document into an empty
2593    /// inventory. It did, in the first draft of this change.
2594    #[test]
2595    fn a_payload_without_positions_is_rejected() {
2596        let err = serde_json::from_str::<Inventory>("{}")
2597            .expect_err("an inventory without positions is malformed");
2598        assert!(
2599            err.to_string().contains("missing field"),
2600            "expected a missing-field error, got: {err}",
2601        );
2602    }
2603
2604    /// Mutating a deserialized inventory must not corrupt it.
2605    ///
2606    /// This is the case the rebuild exists for. `add` trusts both caches: it
2607    /// reads `units_cache.get(..).unwrap_or_default()` as the running total and
2608    /// `simple_index` as the lot to merge into. With both empty it read 0 for an
2609    /// inventory already holding 100 USD, wrote that back as the new total, and
2610    /// appended a second cost-less USD lot instead of merging — so a round-tripped
2611    /// 100 USD inventory answered `units("USD") == 5` after adding 5, holding two
2612    /// lots where the type's own invariant allows one.
2613    ///
2614    /// `units()` and `add_headroom_for` both survived that state on their own —
2615    /// one recomputes, the other refuses — which is exactly why it went
2616    /// unnoticed: the read paths were guarded and the WRITE path was not.
2617    #[test]
2618    fn adding_to_a_deserialized_inventory_keeps_the_running_total() {
2619        let mut inv = Inventory::new();
2620        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2621            .expect("fits");
2622
2623        let mut round_tripped: Inventory =
2624            serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2625                .expect("deserialize");
2626        assert_eq!(
2627            round_tripped.units("USD"),
2628            dec!(100),
2629            "the round-trip preserves the total"
2630        );
2631
2632        round_tripped
2633            .add(Position::simple(Amount::new(dec!(5), "USD")))
2634            .expect("fits");
2635
2636        assert_eq!(
2637            round_tripped.units("USD"),
2638            dec!(105),
2639            "add must extend the existing total, not replace it"
2640        );
2641        assert_eq!(
2642            round_tripped.positions().count(),
2643            1,
2644            "a cost-less add merges into the existing lot rather than appending"
2645        );
2646    }
2647
2648    /// `add_headroom_for` treats `needed` as a magnitude, whatever sign it
2649    /// arrives with.
2650    ///
2651    /// A negative `needed` would make the internal sums smaller and return
2652    /// `true` where overflow is possible. `apply` would then skip the snapshot
2653    /// it needed, leaving a failing transaction's earlier postings applied —
2654    /// silent corruption. Not reachable from the in-tree caller, which sums
2655    /// absolute values, but this is a `pub` method (review catch on #1898).
2656    #[test]
2657    fn add_headroom_for_reads_needed_as_a_magnitude() {
2658        let mut inv = Inventory::new();
2659        inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2660            .expect("one MAX position fits");
2661
2662        assert!(
2663            !inv.add_headroom_for("USD", Decimal::ONE),
2664            "at the ceiling, there is no room for one more unit"
2665        );
2666        assert!(
2667            !inv.add_headroom_for("USD", -Decimal::ONE),
2668            "and a negatively-signed magnitude must not manufacture room"
2669        );
2670        assert_eq!(
2671            inv.add_headroom_for("USD", Decimal::ONE),
2672            inv.add_headroom_for("USD", -Decimal::ONE),
2673            "the sign of `needed` cannot change the answer"
2674        );
2675    }
2676
2677    use super::*;
2678    use crate::Cost;
2679    use crate::NaiveDate;
2680    use rust_decimal_macros::dec;
2681
2682    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2683        crate::naive_date(year, month, day).unwrap()
2684    }
2685
2686    #[test]
2687    fn test_empty_inventory() {
2688        let inv = Inventory::new();
2689        assert!(inv.is_empty());
2690        assert_eq!(inv.len(), 0);
2691    }
2692
2693    #[test]
2694    fn test_add_simple() {
2695        let mut inv = Inventory::new();
2696        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2697            .expect("fixture fits in Decimal");
2698
2699        assert!(!inv.is_empty());
2700        assert_eq!(inv.units("USD"), dec!(100));
2701    }
2702
2703    #[test]
2704    fn test_add_merge_simple() {
2705        let mut inv = Inventory::new();
2706        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2707            .expect("fixture fits in Decimal");
2708        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
2709            .expect("fixture fits in Decimal");
2710
2711        // Should merge into one position
2712        assert_eq!(inv.len(), 1);
2713        assert_eq!(inv.units("USD"), dec!(150));
2714    }
2715
2716    #[test]
2717    fn test_add_with_cost_no_merge() {
2718        let mut inv = Inventory::new();
2719
2720        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2721        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2722
2723        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2724            .expect("fixture fits in Decimal");
2725        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2726            .expect("fixture fits in Decimal");
2727
2728        // Should NOT merge - different costs
2729        assert_eq!(inv.len(), 2);
2730        assert_eq!(inv.units("AAPL"), dec!(15));
2731    }
2732
2733    #[test]
2734    fn test_currencies() {
2735        let mut inv = Inventory::new();
2736        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2737            .expect("fixture fits in Decimal");
2738        inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
2739            .expect("fixture fits in Decimal");
2740        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
2741            .expect("fixture fits in Decimal");
2742
2743        let currencies = inv.currencies();
2744        assert_eq!(currencies.len(), 3);
2745        assert!(currencies.contains(&"USD"));
2746        assert!(currencies.contains(&"EUR"));
2747        assert!(currencies.contains(&"AAPL"));
2748    }
2749
2750    #[test]
2751    fn test_reduce_strict_unique() {
2752        let mut inv = Inventory::new();
2753        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2754        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
2755            .expect("fixture fits in Decimal");
2756
2757        let result = inv
2758            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2759            .unwrap();
2760
2761        assert_eq!(inv.units("AAPL"), dec!(5));
2762        assert!(result.cost_basis.is_some());
2763        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
2764    }
2765
2766    #[test]
2767    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
2768        let mut inv = Inventory::new();
2769
2770        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2771        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2772
2773        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2774            .expect("fixture fits in Decimal");
2775        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2776            .expect("fixture fits in Decimal");
2777
2778        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
2779        // spec) against an inventory with lots at different costs is
2780        // genuinely ambiguous and must error. Issue #737.
2781        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
2782
2783        assert!(
2784            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
2785            "expected AmbiguousMatch, got {result:?}"
2786        );
2787        // Inventory unchanged after a failed reduction
2788        assert_eq!(inv.units("AAPL"), dec!(15));
2789    }
2790
2791    #[test]
2792    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
2793        let mut inv = Inventory::new();
2794
2795        // Two lots with identical cost — interchangeable, so FIFO is fine.
2796        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2797
2798        inv.add(Position::with_cost(
2799            Amount::new(dec!(10), "AAPL"),
2800            cost.clone(),
2801        ))
2802        .expect("fixture fits in Decimal");
2803        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
2804            .expect("fixture fits in Decimal");
2805
2806        let result = inv
2807            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
2808            .expect("identical lots should fall back to FIFO without error");
2809
2810        assert_eq!(inv.units("AAPL"), dec!(12));
2811        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
2812    }
2813
2814    #[test]
2815    fn test_reduce_strict_same_cost_different_dates_is_ambiguous() {
2816        // #2097. Two lots at the same cost number, differing only in
2817        // acquisition date. This used to drain them FIFO on the grounds that
2818        // the lots were interchangeable. They are not: whichever survives
2819        // carries its own date, and holding period drives the short/long
2820        // split in `report capgains` and per-lot IRR eligibility. Beancount
2821        // rejects it too — `booking_method_STRICT` has no fallback.
2822        let mut inv = Inventory::new();
2823
2824        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
2825        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
2826
2827        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2828            .expect("fixture fits in Decimal");
2829        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2830            .expect("fixture fits in Decimal");
2831
2832        let err = inv
2833            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2834            .expect_err("a partial sale cannot choose between two dated lots");
2835        assert!(
2836            matches!(err, BookingError::AmbiguousMatch { num_matches: 2, .. }),
2837            "expected AmbiguousMatch over the two dated lots, got {err:?}"
2838        );
2839
2840        // And it left the inventory alone.
2841        assert_eq!(inv.units("AAPL"), dec!(20));
2842    }
2843
2844    #[test]
2845    fn test_reduce_strict_selling_every_matched_lot_is_not_ambiguous() {
2846        // The total-match exception, which beancount has too: consume every
2847        // matched lot and no lot survives to carry a date, so the choice
2848        // cannot be observed.
2849        let mut inv = Inventory::new();
2850
2851        inv.add(Position::with_cost(
2852            Amount::new(dec!(10), "AAPL"),
2853            Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15)),
2854        ))
2855        .expect("fixture fits in Decimal");
2856        inv.add(Position::with_cost(
2857            Amount::new(dec!(10), "AAPL"),
2858            Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15)),
2859        ))
2860        .expect("fixture fits in Decimal");
2861
2862        let result = inv
2863            .reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Strict)
2864            .expect("selling the whole matched set names no lot to choose");
2865        assert_eq!(inv.units("AAPL"), dec!(0));
2866        assert_eq!(result.cost_basis.unwrap().number, dec!(3000.00));
2867    }
2868
2869    #[test]
2870    fn test_reduce_strict_multiple_match_total_match_exception() {
2871        let mut inv = Inventory::new();
2872
2873        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2874        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2875
2876        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2877            .expect("fixture fits in Decimal");
2878        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2879            .expect("fixture fits in Decimal");
2880
2881        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
2882        // even with mixed costs — the user is liquidating the position.
2883        let result = inv
2884            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
2885            .expect("total-match exception should accept a full liquidation");
2886
2887        assert_eq!(inv.units("AAPL"), dec!(0));
2888        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
2889        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
2890    }
2891
2892    #[test]
2893    fn test_reduce_strict_with_spec() {
2894        let mut inv = Inventory::new();
2895
2896        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2897        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2898
2899        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2900            .expect("fixture fits in Decimal");
2901        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2902            .expect("fixture fits in Decimal");
2903
2904        // Reducing with cost spec should work
2905        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
2906        let result = inv
2907            .reduce(
2908                &Amount::new(dec!(-3), "AAPL"),
2909                Some(&spec),
2910                BookingMethod::Strict,
2911            )
2912            .unwrap();
2913
2914        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
2915        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
2916    }
2917
2918    #[test]
2919    fn test_reduce_fifo() {
2920        let mut inv = Inventory::new();
2921
2922        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2923        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2924        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2925
2926        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2927            .expect("fixture fits in Decimal");
2928        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2929            .expect("fixture fits in Decimal");
2930        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
2931            .expect("fixture fits in Decimal");
2932
2933        // FIFO should reduce from oldest (cost 100) first
2934        let result = inv
2935            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
2936            .unwrap();
2937
2938        assert_eq!(inv.units("AAPL"), dec!(15));
2939        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
2940        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
2941    }
2942
2943    #[test]
2944    fn test_reduce_lifo() {
2945        let mut inv = Inventory::new();
2946
2947        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2948        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2949        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2950
2951        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2952            .expect("fixture fits in Decimal");
2953        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2954            .expect("fixture fits in Decimal");
2955        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
2956            .expect("fixture fits in Decimal");
2957
2958        // LIFO should reduce from newest (cost 200) first
2959        let result = inv
2960            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
2961            .unwrap();
2962
2963        assert_eq!(inv.units("AAPL"), dec!(15));
2964        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
2965        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
2966    }
2967
2968    #[test]
2969    fn test_reduce_insufficient() {
2970        let mut inv = Inventory::new();
2971        let cost = Cost::new(dec!(150.00), "USD");
2972        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
2973            .expect("fixture fits in Decimal");
2974
2975        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
2976
2977        assert!(matches!(
2978            result,
2979            Err(BookingError::InsufficientUnits { .. })
2980        ));
2981    }
2982
2983    #[test]
2984    fn test_book_value() {
2985        let mut inv = Inventory::new();
2986
2987        let cost1 = Cost::new(dec!(100.00), "USD");
2988        let cost2 = Cost::new(dec!(150.00), "USD");
2989
2990        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2991            .expect("fixture fits in Decimal");
2992        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2993            .expect("fixture fits in Decimal");
2994
2995        let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
2996        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
2997    }
2998
2999    #[test]
3000    fn test_display() {
3001        let mut inv = Inventory::new();
3002        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3003            .expect("fixture fits in Decimal");
3004
3005        let s = format!("{inv}");
3006        assert!(s.contains("100 USD"));
3007    }
3008
3009    #[test]
3010    fn test_display_empty() {
3011        let inv = Inventory::new();
3012        assert_eq!(format!("{inv}"), "(empty)");
3013    }
3014
3015    #[test]
3016    fn test_from_iterator() {
3017        let positions = vec![
3018            Position::simple(Amount::new(dec!(100), "USD")),
3019            Position::simple(Amount::new(dec!(50), "USD")),
3020        ];
3021
3022        let inv = Inventory::try_from_positions(positions).expect("fixture fits in Decimal");
3023        assert_eq!(inv.units("USD"), dec!(150));
3024    }
3025
3026    #[test]
3027    fn test_add_costed_positions_kept_separate() {
3028        // Costed positions are kept as separate lots for O(1) add performance.
3029        // Aggregation happens at display time (in query output).
3030        let mut inv = Inventory::new();
3031
3032        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3033
3034        // Buy 10 shares
3035        inv.add(Position::with_cost(
3036            Amount::new(dec!(10), "AAPL"),
3037            cost.clone(),
3038        ))
3039        .expect("fixture fits in Decimal");
3040        assert_eq!(inv.len(), 1);
3041        assert_eq!(inv.units("AAPL"), dec!(10));
3042
3043        // Sell 10 shares - kept as separate lot for tracking
3044        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3045            .expect("fixture fits in Decimal");
3046        assert_eq!(inv.len(), 2); // Both lots kept
3047        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
3048    }
3049
3050    /// A deserialized inventory must report the same scale as one built by
3051    /// incremental `add`s.
3052    ///
3053    /// `units_cache` is `#[serde(skip)]`, so `try_rebuild_index_from` is what
3054    /// reconstructs it after a round-trip. That rebuild re-sums the positions;
3055    /// if it used a raw `checked_add` while `add` used the Python scale rule,
3056    /// the two would part company the moment the running sum crossed zero —
3057    /// the same money reporting `1.00` from one path and `1` from the other,
3058    /// silently, depending only on whether it had been serialized.
3059    ///
3060    /// Needs COST-BEARING lots. Cost-less positions coalesce into a single
3061    /// position, and one position cannot cross zero during the rebuild, so a
3062    /// simpler fixture passes either way and pins nothing.
3063    #[test]
3064    fn a_round_trip_reports_the_same_scale_as_incremental_adds() {
3065        let mut inv = Inventory::new();
3066        let lots = [
3067            (
3068                dec!(2.00),
3069                Cost::new(dec!(10.00), "USD").with_date(date(2024, 1, 1)),
3070            ),
3071            (
3072                dec!(-2.00),
3073                Cost::new(dec!(11.00), "USD").with_date(date(2024, 1, 2)),
3074            ),
3075            (
3076                dec!(1),
3077                Cost::new(dec!(12.00), "USD").with_date(date(2024, 1, 3)),
3078            ),
3079        ];
3080        for (units, cost) in lots {
3081            inv.add(Position::with_cost(Amount::new(units, "SH"), cost))
3082                .expect("fixture fits in Decimal");
3083        }
3084
3085        let built = inv.units("SH").to_string();
3086        assert_eq!(built, "1.00", "the incrementally-built total");
3087
3088        let json = serde_json::to_string(&inv).expect("serializes");
3089        let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
3090        assert_eq!(
3091            round_tripped.units("SH").to_string(),
3092            built,
3093            "a serde round-trip must not change the reported scale",
3094        );
3095    }
3096
3097    /// Coalescing must not make a balance depend on the order it was built in.
3098    ///
3099    /// `rust_decimal` returns the other operand untouched when one side is
3100    /// zero, so a running total that passes through zero loses its scale and
3101    /// every later addend renders one scale narrower. The two inventories
3102    /// below hold the SAME multiset of amounts in a different order.
3103    ///
3104    /// Asserts on `to_string()`, not on `Decimal` equality: `==` compares
3105    /// value and ignores scale (`dec!(1) == dec!(1.00)`), so a value-level
3106    /// assertion here would pass against the bug it is pinning.
3107    #[test]
3108    fn coalescing_is_independent_of_the_order_amounts_arrive_in() {
3109        // Passes through 0.00 (scale 2), then takes a scale-0 addend.
3110        let zero_crossing_first = [dec!(-2.00), dec!(2.00), dec!(-1)];
3111        // Same amounts, no zero crossing before the scale-0 addend.
3112        let zero_crossing_last = [dec!(-1), dec!(-2.00), dec!(2.00)];
3113
3114        let build = |amounts: &[Decimal]| {
3115            let mut inv = Inventory::new();
3116            for n in amounts {
3117                inv.add(Position::simple(Amount::new(*n, "USD")))
3118                    .expect("fixture fits in Decimal");
3119            }
3120            inv
3121        };
3122
3123        let a = build(&zero_crossing_first);
3124        let b = build(&zero_crossing_last);
3125
3126        // The merged POSITION.
3127        assert_eq!(
3128            a.positions()
3129                .next()
3130                .expect("one position")
3131                .units
3132                .number
3133                .to_string(),
3134            "-1.00",
3135            "a total that passed through zero must keep the widest scale",
3136        );
3137        assert_eq!(
3138            b.positions()
3139                .next()
3140                .expect("one position")
3141                .units
3142                .number
3143                .to_string(),
3144            "-1.00",
3145        );
3146
3147        // And the units CACHE, which is maintained separately and would
3148        // otherwise disagree with the position it summarizes.
3149        assert_eq!(a.units("USD").to_string(), "-1.00");
3150        assert_eq!(b.units("USD").to_string(), "-1.00");
3151    }
3152
3153    #[test]
3154    fn test_add_costed_positions_net_units() {
3155        // Verify that units() correctly sums across all lots
3156        let mut inv = Inventory::new();
3157
3158        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3159
3160        // Buy 10 shares
3161        inv.add(Position::with_cost(
3162            Amount::new(dec!(10), "AAPL"),
3163            cost.clone(),
3164        ))
3165        .expect("fixture fits in Decimal");
3166
3167        // Sell 3 shares - kept as separate lot
3168        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost))
3169            .expect("fixture fits in Decimal");
3170        assert_eq!(inv.len(), 2); // Both lots kept
3171        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
3172    }
3173
3174    #[test]
3175    fn test_add_no_cancel_different_cost() {
3176        // Test that different costs don't cancel
3177        let mut inv = Inventory::new();
3178
3179        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3180        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
3181
3182        // Buy 10 shares at 150
3183        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3184            .expect("fixture fits in Decimal");
3185
3186        // Sell 5 shares at 160 - should NOT cancel (different cost)
3187        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2))
3188            .expect("fixture fits in Decimal");
3189
3190        // Should have two separate lots
3191        assert_eq!(inv.len(), 2);
3192        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
3193    }
3194
3195    #[test]
3196    fn test_add_no_cancel_same_sign() {
3197        // Test that same-sign positions don't merge even with same cost
3198        let mut inv = Inventory::new();
3199
3200        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3201
3202        // Buy 10 shares
3203        inv.add(Position::with_cost(
3204            Amount::new(dec!(10), "AAPL"),
3205            cost.clone(),
3206        ))
3207        .expect("fixture fits in Decimal");
3208
3209        // Buy 5 more shares with same cost - should NOT merge
3210        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
3211            .expect("fixture fits in Decimal");
3212
3213        // Should have two separate lots (different acquisitions)
3214        assert_eq!(inv.len(), 2);
3215        assert_eq!(inv.units("AAPL"), dec!(15));
3216    }
3217
3218    #[test]
3219    fn test_merge_keeps_lots_separate() {
3220        // Test that merge keeps costed lots separate (aggregation at display time)
3221        let mut inv1 = Inventory::new();
3222        let mut inv2 = Inventory::new();
3223
3224        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3225
3226        // inv1: buy 10 shares
3227        inv1.add(Position::with_cost(
3228            Amount::new(dec!(10), "AAPL"),
3229            cost.clone(),
3230        ))
3231        .expect("fixture fits in Decimal");
3232
3233        // inv2: sell 10 shares
3234        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3235            .expect("fixture fits in Decimal");
3236
3237        // Merge keeps both lots, net units is zero
3238        inv1.merge(&inv2).expect("fixture fits in Decimal");
3239        assert_eq!(inv1.len(), 2); // Both lots preserved
3240        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
3241    }
3242
3243    // ====================================================================
3244    // Phase 2: Additional Coverage Tests for Booking Methods
3245    // ====================================================================
3246
3247    #[test]
3248    fn test_hifo_with_tie_breaking() {
3249        // When multiple lots have the same cost, HIFO should use insertion order
3250        let mut inv = Inventory::new();
3251
3252        // Three lots with same cost but different dates
3253        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3254        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3255        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
3256
3257        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3258            .expect("fixture fits in Decimal");
3259        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3260            .expect("fixture fits in Decimal");
3261        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
3262            .expect("fixture fits in Decimal");
3263
3264        // HIFO with tied costs should reduce in some deterministic order
3265        let result = inv
3266            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3267            .unwrap();
3268
3269        assert_eq!(inv.units("AAPL"), dec!(15));
3270        // All at same cost, so 15 * 100 = 1500
3271        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3272    }
3273
3274    #[test]
3275    fn test_hifo_with_different_costs() {
3276        // HIFO should reduce highest cost lots first
3277        let mut inv = Inventory::new();
3278
3279        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
3280        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3281        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
3282
3283        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
3284            .expect("fixture fits in Decimal");
3285        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
3286            .expect("fixture fits in Decimal");
3287        inv.add(Position::with_cost(
3288            Amount::new(dec!(10), "AAPL"),
3289            cost_high,
3290        ))
3291        .expect("fixture fits in Decimal");
3292
3293        // Reduce 15 shares - should take from highest cost (200) first
3294        let result = inv
3295            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3296            .unwrap();
3297
3298        assert_eq!(inv.units("AAPL"), dec!(15));
3299        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
3300        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
3301    }
3302
3303    #[test]
3304    fn test_average_booking_with_pre_existing_positions() {
3305        let mut inv = Inventory::new();
3306
3307        // Add two lots with different costs
3308        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3309        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
3310
3311        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3312            .expect("fixture fits in Decimal");
3313        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3314            .expect("fixture fits in Decimal");
3315
3316        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
3317        // Reduce 5 shares using AVERAGE
3318        let result = inv
3319            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
3320            .unwrap();
3321
3322        assert_eq!(inv.units("AAPL"), dec!(15));
3323        // Cost basis for 5 shares at average 150 = 750
3324        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
3325    }
3326
3327    #[test]
3328    fn test_average_booking_reduces_all() {
3329        let mut inv = Inventory::new();
3330
3331        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3332        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3333            .expect("fixture fits in Decimal");
3334
3335        // Reduce all shares
3336        let result = inv
3337            .reduce(
3338                &Amount::new(dec!(-10), "AAPL"),
3339                None,
3340                BookingMethod::Average,
3341            )
3342            .unwrap();
3343
3344        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
3345        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
3346    }
3347
3348    #[test]
3349    fn test_none_booking_augmentation() {
3350        // NONE booking with same-sign amounts should augment, not reduce
3351        let mut inv = Inventory::new();
3352        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3353            .expect("fixture fits in Decimal");
3354
3355        // Adding more (same sign) - this is an augmentation
3356        let result = inv
3357            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
3358            .unwrap();
3359
3360        assert_eq!(inv.units("USD"), dec!(150));
3361        assert!(result.matched.is_empty()); // No lots matched for augmentation
3362        assert!(result.cost_basis.is_none());
3363    }
3364
3365    #[test]
3366    fn test_none_booking_reduction() {
3367        // NONE booking with opposite-sign should reduce
3368        let mut inv = Inventory::new();
3369        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3370            .expect("fixture fits in Decimal");
3371
3372        let result = inv
3373            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
3374            .unwrap();
3375
3376        assert_eq!(inv.units("USD"), dec!(70));
3377        assert!(!result.matched.is_empty());
3378    }
3379
3380    #[test]
3381    fn test_none_booking_shorts_past_zero() {
3382        let mut inv = Inventory::new();
3383        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3384            .expect("fixture fits in Decimal");
3385
3386        // NONE performs no booking: reducing past the balance shorts instead
3387        // of erroring (#1686 — previously InsufficientUnits, inconsistent
3388        // with the zero-balance case, NONECorrect.tla, and beancount NONE).
3389        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
3390
3391        assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
3392        assert_eq!(inv.units("USD"), dec!(-50));
3393    }
3394
3395    #[test]
3396    fn test_booking_error_no_matching_lot() {
3397        let mut inv = Inventory::new();
3398
3399        // Add a lot with specific cost
3400        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3401        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3402            .expect("fixture fits in Decimal");
3403
3404        // Try to reduce with a cost spec that doesn't match
3405        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
3406        let result = inv.reduce(
3407            &Amount::new(dec!(-5), "AAPL"),
3408            Some(&wrong_spec),
3409            BookingMethod::Strict,
3410        );
3411
3412        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
3413    }
3414
3415    #[test]
3416    fn test_booking_error_insufficient_units() {
3417        let mut inv = Inventory::new();
3418
3419        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3420        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3421            .expect("fixture fits in Decimal");
3422
3423        // Try to reduce more than available
3424        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
3425
3426        match result {
3427            Err(BookingError::InsufficientUnits {
3428                requested,
3429                available,
3430                ..
3431            }) => {
3432                assert_eq!(requested, dec!(20));
3433                assert_eq!(available, dec!(10));
3434            }
3435            _ => panic!("Expected InsufficientUnits error"),
3436        }
3437    }
3438
3439    #[test]
3440    fn test_strict_with_size_exact_match() {
3441        let mut inv = Inventory::new();
3442
3443        // Add two lots with same cost but different sizes
3444        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3445        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3446
3447        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3448            .expect("fixture fits in Decimal");
3449        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3450            .expect("fixture fits in Decimal");
3451
3452        // Reduce exactly 5 - should match the 5-share lot
3453        let result = inv
3454            .reduce(
3455                &Amount::new(dec!(-5), "AAPL"),
3456                None,
3457                BookingMethod::StrictWithSize,
3458            )
3459            .unwrap();
3460
3461        assert_eq!(inv.units("AAPL"), dec!(10));
3462        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
3463    }
3464
3465    #[test]
3466    fn test_strict_with_size_total_match() {
3467        let mut inv = Inventory::new();
3468
3469        // Add two lots
3470        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3471        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3472
3473        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3474            .expect("fixture fits in Decimal");
3475        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3476            .expect("fixture fits in Decimal");
3477
3478        // Reduce exactly 15 (total) - should succeed via total match exception
3479        let result = inv
3480            .reduce(
3481                &Amount::new(dec!(-15), "AAPL"),
3482                None,
3483                BookingMethod::StrictWithSize,
3484            )
3485            .unwrap();
3486
3487        assert_eq!(inv.units("AAPL"), dec!(0));
3488        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3489    }
3490
3491    #[test]
3492    fn test_strict_with_size_ambiguous() {
3493        let mut inv = Inventory::new();
3494
3495        // Add two lots of same size and cost
3496        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3497        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3498
3499        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3500            .expect("fixture fits in Decimal");
3501        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3502            .expect("fixture fits in Decimal");
3503
3504        // Reduce 7 shares - doesn't match either lot exactly, not total
3505        let result = inv.reduce(
3506            &Amount::new(dec!(-7), "AAPL"),
3507            None,
3508            BookingMethod::StrictWithSize,
3509        );
3510
3511        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
3512    }
3513
3514    #[test]
3515    fn test_short_position() {
3516        // Test short selling (negative positions)
3517        let mut inv = Inventory::new();
3518
3519        // Short 10 shares
3520        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3521        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3522            .expect("fixture fits in Decimal");
3523
3524        assert_eq!(inv.units("AAPL"), dec!(-10));
3525        assert!(!inv.is_empty());
3526    }
3527
3528    #[test]
3529    fn test_at_cost() {
3530        let mut inv = Inventory::new();
3531
3532        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3533        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3534
3535        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3536            .expect("fixture fits in Decimal");
3537        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3538            .expect("fixture fits in Decimal");
3539        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3540            .expect("fixture fits in Decimal");
3541
3542        let at_cost = inv.at_cost().expect("fixture fits in Decimal");
3543
3544        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
3545        // Plus 100 USD simple position = 1850 USD total
3546        assert_eq!(at_cost.units("USD"), dec!(1850));
3547        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
3548    }
3549
3550    #[test]
3551    fn test_at_units() {
3552        let mut inv = Inventory::new();
3553
3554        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3555        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3556
3557        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3558            .expect("fixture fits in Decimal");
3559        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3560            .expect("fixture fits in Decimal");
3561
3562        let at_units = inv.at_units().expect("fixture fits in Decimal");
3563
3564        // All AAPL lots merged
3565        assert_eq!(at_units.units("AAPL"), dec!(15));
3566        // Should only have one position after aggregation
3567        assert_eq!(at_units.len(), 1);
3568    }
3569
3570    #[test]
3571    fn test_add_empty_position() {
3572        let mut inv = Inventory::new();
3573        inv.add(Position::simple(Amount::new(dec!(0), "USD")))
3574            .expect("fixture fits in Decimal");
3575
3576        assert!(inv.is_empty());
3577        assert_eq!(inv.len(), 0);
3578    }
3579
3580    #[test]
3581    fn test_compact() {
3582        let mut inv = Inventory::new();
3583
3584        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3585        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3586            .expect("fixture fits in Decimal");
3587
3588        // Reduce all
3589        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
3590            .unwrap();
3591
3592        // Compact to remove empty positions
3593        inv.compact();
3594        assert!(inv.is_empty());
3595        assert_eq!(inv.len(), 0);
3596    }
3597
3598    #[test]
3599    fn test_booking_method_from_str() {
3600        assert_eq!(
3601            BookingMethod::from_str("STRICT").unwrap(),
3602            BookingMethod::Strict
3603        );
3604        assert_eq!(
3605            BookingMethod::from_str("fifo").unwrap(),
3606            BookingMethod::Fifo
3607        );
3608        assert_eq!(
3609            BookingMethod::from_str("LIFO").unwrap(),
3610            BookingMethod::Lifo
3611        );
3612        assert_eq!(
3613            BookingMethod::from_str("Hifo").unwrap(),
3614            BookingMethod::Hifo
3615        );
3616        assert_eq!(
3617            BookingMethod::from_str("AVERAGE").unwrap(),
3618            BookingMethod::Average
3619        );
3620        assert_eq!(
3621            BookingMethod::from_str("NONE").unwrap(),
3622            BookingMethod::None
3623        );
3624        assert_eq!(
3625            BookingMethod::from_str("strict_with_size").unwrap(),
3626            BookingMethod::StrictWithSize
3627        );
3628        assert!(BookingMethod::from_str("INVALID").is_err());
3629    }
3630
3631    #[test]
3632    fn test_booking_method_display() {
3633        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
3634        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
3635        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
3636        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
3637        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
3638        assert_eq!(format!("{}", BookingMethod::None), "NONE");
3639        assert_eq!(
3640            format!("{}", BookingMethod::StrictWithSize),
3641            "STRICT_WITH_SIZE"
3642        );
3643    }
3644
3645    #[test]
3646    fn test_booking_error_display() {
3647        let err = BookingError::AmbiguousMatch {
3648            num_matches: 3,
3649            currency: "AAPL".into(),
3650        };
3651        assert!(format!("{err}").contains("3 lots match"));
3652
3653        let err = BookingError::NoMatchingLot {
3654            currency: "AAPL".into(),
3655            cost_spec: CostSpec::empty(),
3656        };
3657        assert!(format!("{err}").contains("No matching lot"));
3658
3659        let err = BookingError::InsufficientUnits {
3660            currency: "AAPL".into(),
3661            requested: dec!(100),
3662            available: dec!(50),
3663        };
3664        assert!(format!("{err}").contains("requested 100"));
3665        assert!(format!("{err}").contains("available 50"));
3666
3667        let err = BookingError::CurrencyMismatch {
3668            expected: "USD".into(),
3669            got: "EUR".into(),
3670        };
3671        assert!(format!("{err}").contains("expected USD"));
3672        assert!(format!("{err}").contains("got EUR"));
3673    }
3674
3675    #[test]
3676    fn test_book_value_multiple_currencies() {
3677        let mut inv = Inventory::new();
3678
3679        // Cost in USD
3680        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3681        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd))
3682            .expect("fixture fits in Decimal");
3683
3684        // Cost in EUR
3685        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
3686        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur))
3687            .expect("fixture fits in Decimal");
3688
3689        let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
3690        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
3691        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
3692    }
3693
3694    #[test]
3695    fn test_reduce_hifo_insufficient_units() {
3696        let mut inv = Inventory::new();
3697
3698        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3699        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3700            .expect("fixture fits in Decimal");
3701
3702        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
3703
3704        assert!(matches!(
3705            result,
3706            Err(BookingError::InsufficientUnits { .. })
3707        ));
3708    }
3709
3710    #[test]
3711    fn test_reduce_average_insufficient_units() {
3712        let mut inv = Inventory::new();
3713
3714        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3715        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3716            .expect("fixture fits in Decimal");
3717
3718        let result = inv.reduce(
3719            &Amount::new(dec!(-20), "AAPL"),
3720            None,
3721            BookingMethod::Average,
3722        );
3723
3724        assert!(matches!(
3725            result,
3726            Err(BookingError::InsufficientUnits { .. })
3727        ));
3728    }
3729
3730    #[test]
3731    fn test_reduce_average_empty_inventory() {
3732        let mut inv = Inventory::new();
3733
3734        let result = inv.reduce(
3735            &Amount::new(dec!(-10), "AAPL"),
3736            None,
3737            BookingMethod::Average,
3738        );
3739
3740        assert!(matches!(
3741            result,
3742            Err(BookingError::InsufficientUnits { .. })
3743        ));
3744    }
3745
3746    #[test]
3747    fn test_reduce_merge_operator() {
3748        // {*} merge: two lots merged into weighted-average, then reduced
3749        let mut inv = Inventory::new();
3750        inv.add(Position::with_cost(
3751            Amount::new(dec!(10), "AAPL"),
3752            Cost::new(dec!(150), "USD"),
3753        ))
3754        .expect("fixture fits in Decimal");
3755        inv.add(Position::with_cost(
3756            Amount::new(dec!(10), "AAPL"),
3757            Cost::new(dec!(160), "USD"),
3758        ))
3759        .expect("fixture fits in Decimal");
3760
3761        let merge_spec = CostSpec::empty().with_merge();
3762        let result = inv
3763            .reduce(
3764                &Amount::new(dec!(-5), "AAPL"),
3765                Some(&merge_spec),
3766                BookingMethod::Strict,
3767            )
3768            .expect("merge reduction should succeed");
3769
3770        // Cost basis: 5 units * 155 USD average = 775 USD
3771        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
3772
3773        // Inventory should have a single merged lot with 15 remaining @ 155
3774        assert_eq!(inv.positions.len(), 1);
3775        // Through the iterator, not a raw slot: the merged lot is appended
3776        // after the tombstoned originals, and the iterator is what every
3777        // consumer sees.
3778        let merged = inv.positions().next().expect("one merged lot");
3779        assert_eq!(merged.units.number, dec!(15));
3780        let cost = merged.cost.as_ref().expect("should have cost");
3781        assert_eq!(cost.number, dec!(155));
3782    }
3783
3784    #[test]
3785    fn test_reduce_merge_insufficient_units() {
3786        let mut inv = Inventory::new();
3787        inv.add(Position::with_cost(
3788            Amount::new(dec!(10), "AAPL"),
3789            Cost::new(dec!(150), "USD"),
3790        ))
3791        .expect("fixture fits in Decimal");
3792
3793        let merge_spec = CostSpec::empty().with_merge();
3794        let result = inv.reduce(
3795            &Amount::new(dec!(-20), "AAPL"),
3796            Some(&merge_spec),
3797            BookingMethod::Strict,
3798        );
3799
3800        assert!(matches!(
3801            result,
3802            Err(BookingError::InsufficientUnits { .. })
3803        ));
3804    }
3805
3806    #[test]
3807    fn test_reduce_merge_sells_all() {
3808        // Merge and sell entire position
3809        let mut inv = Inventory::new();
3810        inv.add(Position::with_cost(
3811            Amount::new(dec!(10), "AAPL"),
3812            Cost::new(dec!(150), "USD"),
3813        ))
3814        .expect("fixture fits in Decimal");
3815        inv.add(Position::with_cost(
3816            Amount::new(dec!(10), "AAPL"),
3817            Cost::new(dec!(160), "USD"),
3818        ))
3819        .expect("fixture fits in Decimal");
3820
3821        let merge_spec = CostSpec::empty().with_merge();
3822        let result = inv
3823            .reduce(
3824                &Amount::new(dec!(-20), "AAPL"),
3825                Some(&merge_spec),
3826                BookingMethod::Strict,
3827            )
3828            .expect("merge reduction should succeed");
3829
3830        // Cost basis: 20 * 155 = 3100 USD
3831        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
3832
3833        // Inventory should be empty
3834        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
3835    }
3836
3837    #[test]
3838    fn test_reduce_merge_single_lot() {
3839        // {*} with a single lot should work trivially
3840        let mut inv = Inventory::new();
3841        inv.add(Position::with_cost(
3842            Amount::new(dec!(10), "AAPL"),
3843            Cost::new(dec!(150), "USD"),
3844        ))
3845        .expect("fixture fits in Decimal");
3846
3847        let merge_spec = CostSpec::empty().with_merge();
3848        let result = inv
3849            .reduce(
3850                &Amount::new(dec!(-3), "AAPL"),
3851                Some(&merge_spec),
3852                BookingMethod::Strict,
3853            )
3854            .expect("single-lot merge should succeed");
3855
3856        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
3857        assert_eq!(inv.positions.len(), 1);
3858        // Iterator, not a raw slot: the merged lot is appended after the
3859        // tombstoned originals.
3860        let merged = inv.positions().next().expect("one merged lot");
3861        assert_eq!(merged.units.number, dec!(7));
3862    }
3863
3864    #[test]
3865    fn test_reduce_merge_three_lots() {
3866        // {*} with three lots at different costs
3867        let mut inv = Inventory::new();
3868        inv.add(Position::with_cost(
3869            Amount::new(dec!(10), "AAPL"),
3870            Cost::new(dec!(100), "USD"),
3871        ))
3872        .expect("fixture fits in Decimal");
3873        inv.add(Position::with_cost(
3874            Amount::new(dec!(10), "AAPL"),
3875            Cost::new(dec!(150), "USD"),
3876        ))
3877        .expect("fixture fits in Decimal");
3878        inv.add(Position::with_cost(
3879            Amount::new(dec!(10), "AAPL"),
3880            Cost::new(dec!(200), "USD"),
3881        ))
3882        .expect("fixture fits in Decimal");
3883
3884        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
3885        let merge_spec = CostSpec::empty().with_merge();
3886        let result = inv
3887            .reduce(
3888                &Amount::new(dec!(-6), "AAPL"),
3889                Some(&merge_spec),
3890                BookingMethod::Strict,
3891            )
3892            .expect("three-lot merge should succeed");
3893
3894        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
3895        assert_eq!(inv.positions.len(), 1);
3896        // Iterator, not a raw slot: the merged lot is appended after the
3897        // tombstoned originals.
3898        let merged = inv.positions().next().expect("one merged lot");
3899        assert_eq!(merged.units.number, dec!(24));
3900        let cost = merged.cost.as_ref().expect("should have cost");
3901        assert_eq!(cost.number, dec!(150));
3902    }
3903
3904    #[test]
3905    fn test_reduce_merge_mixed_cost_currencies_errors() {
3906        // Lots with different cost currencies cannot be merged
3907        let mut inv = Inventory::new();
3908        inv.add(Position::with_cost(
3909            Amount::new(dec!(10), "AAPL"),
3910            Cost::new(dec!(150), "USD"),
3911        ))
3912        .expect("fixture fits in Decimal");
3913        inv.add(Position::with_cost(
3914            Amount::new(dec!(10), "AAPL"),
3915            Cost::new(dec!(130), "EUR"),
3916        ))
3917        .expect("fixture fits in Decimal");
3918
3919        let merge_spec = CostSpec::empty().with_merge();
3920        let result = inv.reduce(
3921            &Amount::new(dec!(-5), "AAPL"),
3922            Some(&merge_spec),
3923            BookingMethod::Strict,
3924        );
3925
3926        assert!(
3927            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
3928            "expected CurrencyMismatch, got {result:?}"
3929        );
3930    }
3931
3932    #[test]
3933    fn test_reduce_merge_empty_inventory() {
3934        let mut inv = Inventory::new();
3935
3936        let merge_spec = CostSpec::empty().with_merge();
3937        let result = inv.reduce(
3938            &Amount::new(dec!(-5), "AAPL"),
3939            Some(&merge_spec),
3940            BookingMethod::Strict,
3941        );
3942
3943        assert!(matches!(
3944            result,
3945            Err(BookingError::InsufficientUnits { .. })
3946        ));
3947    }
3948
3949    #[test]
3950    fn test_inventory_display_sorted() {
3951        let mut inv = Inventory::new();
3952
3953        // Add in non-alphabetical order
3954        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3955            .expect("fixture fits in Decimal");
3956        inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
3957            .expect("fixture fits in Decimal");
3958        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
3959            .expect("fixture fits in Decimal");
3960
3961        let display = format!("{inv}");
3962
3963        // Should be sorted alphabetically: AAPL, EUR, USD
3964        let aapl_pos = display.find("AAPL").unwrap();
3965        let eur_pos = display.find("EUR").unwrap();
3966        let usd_pos = display.find("USD").unwrap();
3967
3968        assert!(aapl_pos < eur_pos);
3969        assert!(eur_pos < usd_pos);
3970    }
3971
3972    #[test]
3973    fn test_inventory_with_cost_display_sorted() {
3974        let mut inv = Inventory::new();
3975
3976        // Add same currency with different costs
3977        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
3978        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3979
3980        inv.add(Position::with_cost(
3981            Amount::new(dec!(10), "AAPL"),
3982            cost_high,
3983        ))
3984        .expect("fixture fits in Decimal");
3985        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
3986            .expect("fixture fits in Decimal");
3987
3988        let display = format!("{inv}");
3989
3990        // Both positions should be in the output
3991        assert!(display.contains("AAPL"));
3992        assert!(display.contains("100"));
3993        assert!(display.contains("200"));
3994    }
3995
3996    #[test]
3997    fn test_reduce_hifo_no_matching_lot() {
3998        let mut inv = Inventory::new();
3999
4000        // No AAPL positions
4001        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
4002            .expect("fixture fits in Decimal");
4003
4004        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
4005
4006        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
4007    }
4008
4009    #[test]
4010    fn test_fifo_respects_dates() {
4011        // Ensure FIFO uses acquisition date, not insertion order
4012        let mut inv = Inventory::new();
4013
4014        // Add newer lot first (out of order)
4015        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4016        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4017
4018        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4019            .expect("fixture fits in Decimal");
4020        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4021            .expect("fixture fits in Decimal");
4022
4023        // FIFO should reduce from oldest (cost 100) first
4024        let result = inv
4025            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
4026            .unwrap();
4027
4028        // Should use cost from oldest lot (100)
4029        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4030    }
4031
4032    #[test]
4033    fn test_lifo_respects_dates() {
4034        // Ensure LIFO uses acquisition date, not insertion order
4035        let mut inv = Inventory::new();
4036
4037        // Add older lot first
4038        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4039        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4040
4041        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4042            .expect("fixture fits in Decimal");
4043        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4044            .expect("fixture fits in Decimal");
4045
4046        // LIFO should reduce from newest (cost 200) first
4047        let result = inv
4048            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
4049            .unwrap();
4050
4051        // Should use cost from newest lot (200)
4052        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
4053    }
4054
4055    // =========================================================================
4056    // Booking method coverage tests
4057    //
4058    // These tests cover gaps identified during the spring 2026 audit:
4059    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
4060    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
4061    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
4062    // - NONE: with cost positions, short position reduction
4063    // =========================================================================
4064
4065    // --- STRICT_WITH_SIZE ---
4066
4067    #[test]
4068    fn test_strict_with_size_different_costs_exact_match() {
4069        // When lots have different costs but one matches the reduction size exactly,
4070        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
4071        let mut inv = Inventory::new();
4072
4073        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4074        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4075
4076        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4077            .expect("fixture fits in Decimal");
4078        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2))
4079            .expect("fixture fits in Decimal");
4080
4081        // Reduce exactly 7 - should match the 7-share lot at cost 200
4082        let result = inv
4083            .reduce(
4084                &Amount::new(dec!(-7), "AAPL"),
4085                None,
4086                BookingMethod::StrictWithSize,
4087            )
4088            .unwrap();
4089
4090        assert_eq!(inv.units("AAPL"), dec!(10));
4091        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
4092    }
4093
4094    #[test]
4095    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
4096        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
4097        // pick the oldest one (first in index order)
4098        let mut inv = Inventory::new();
4099
4100        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4101        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
4102
4103        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1))
4104            .expect("fixture fits in Decimal");
4105        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
4106            .expect("fixture fits in Decimal");
4107
4108        // Both lots are size 5 — should pick the first (oldest) one
4109        let result = inv
4110            .reduce(
4111                &Amount::new(dec!(-5), "AAPL"),
4112                None,
4113                BookingMethod::StrictWithSize,
4114            )
4115            .unwrap();
4116
4117        assert_eq!(inv.units("AAPL"), dec!(5));
4118        // Should use cost from the oldest lot (100)
4119        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4120    }
4121
4122    #[test]
4123    fn test_strict_with_size_with_cost_spec() {
4124        // Cost spec should filter lots before exact-size matching
4125        let mut inv = Inventory::new();
4126
4127        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4128        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4129
4130        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4131            .expect("fixture fits in Decimal");
4132        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4133            .expect("fixture fits in Decimal");
4134
4135        // With cost spec filtering to the 200 USD lot, should find unique match
4136        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
4137            value: dec!(200.00),
4138        });
4139        let result = inv
4140            .reduce(
4141                &Amount::new(dec!(-5), "AAPL"),
4142                Some(&spec),
4143                BookingMethod::StrictWithSize,
4144            )
4145            .unwrap();
4146
4147        assert_eq!(inv.units("AAPL"), dec!(15));
4148        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4149    }
4150
4151    // --- HIFO ---
4152
4153    #[test]
4154    fn test_hifo_reduces_highest_cost_first() {
4155        // HIFO should reduce the highest-cost lot first, regardless of date
4156        let mut inv = Inventory::new();
4157
4158        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4159        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
4160        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4161
4162        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
4163            .expect("fixture fits in Decimal");
4164        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
4165            .expect("fixture fits in Decimal");
4166        inv.add(Position::with_cost(
4167            Amount::new(dec!(10), "AAPL"),
4168            cost_high,
4169        ))
4170        .expect("fixture fits in Decimal");
4171
4172        // Reduce 5 — should come from highest cost lot (200)
4173        let result = inv
4174            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
4175            .unwrap();
4176
4177        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4178        assert_eq!(inv.units("AAPL"), dec!(25));
4179    }
4180
4181    #[test]
4182    fn test_hifo_spans_multiple_lots() {
4183        // When reducing more than the highest-cost lot holds, HIFO should
4184        // continue to the next highest
4185        let mut inv = Inventory::new();
4186
4187        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4188        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4189
4190        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
4191            .expect("fixture fits in Decimal");
4192        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high))
4193            .expect("fixture fits in Decimal");
4194
4195        // Reduce 8: 5 from high (200) + 3 from low (100)
4196        let result = inv
4197            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
4198            .unwrap();
4199
4200        // Cost basis: 5*200 + 3*100 = 1300
4201        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
4202        assert_eq!(inv.units("AAPL"), dec!(2));
4203    }
4204
4205    #[test]
4206    fn test_hifo_with_cost_spec_filter() {
4207        // Cost spec should filter lots before HIFO ordering
4208        let mut inv = Inventory::new();
4209
4210        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4211        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
4212
4213        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4214            .expect("fixture fits in Decimal");
4215        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4216            .expect("fixture fits in Decimal");
4217
4218        // Filter to USD lots only
4219        let spec = CostSpec::empty().with_currency("USD");
4220        let result = inv
4221            .reduce(
4222                &Amount::new(dec!(-5), "AAPL"),
4223                Some(&spec),
4224                BookingMethod::Hifo,
4225            )
4226            .unwrap();
4227
4228        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
4229    }
4230
4231    #[test]
4232    fn test_hifo_short_position() {
4233        // HIFO with short positions: covering shorts should work correctly
4234        let mut inv = Inventory::new();
4235
4236        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4237        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4238
4239        // Short positions (negative units)
4240        inv.add(Position::with_cost(
4241            Amount::new(dec!(-10), "AAPL"),
4242            cost_low,
4243        ))
4244        .expect("fixture fits in Decimal");
4245        inv.add(Position::with_cost(
4246            Amount::new(dec!(-10), "AAPL"),
4247            cost_high,
4248        ))
4249        .expect("fixture fits in Decimal");
4250
4251        // Cover 5 shares (positive = reduce short position)
4252        // HIFO should pick the highest-cost short lot (200)
4253        let result = inv
4254            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
4255            .unwrap();
4256
4257        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4258        assert_eq!(inv.units("AAPL"), dec!(-15));
4259    }
4260
4261    // --- AVERAGE ---
4262
4263    #[test]
4264    fn test_average_weighted_cost() {
4265        // AVERAGE should compute weighted average across lots with different costs
4266        let mut inv = Inventory::new();
4267
4268        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4269        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4270
4271        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4272            .expect("fixture fits in Decimal");
4273        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4274            .expect("fixture fits in Decimal");
4275
4276        // Average cost = (10*100 + 10*200) / 20 = 150
4277        let result = inv
4278            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4279            .unwrap();
4280
4281        // Cost basis: 5 * 150 = 750
4282        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
4283        assert_eq!(inv.units("AAPL"), dec!(15));
4284    }
4285
4286    #[test]
4287    fn test_average_merges_into_single_position() {
4288        // After AVERAGE reduction, inventory should have a single simple position
4289        let mut inv = Inventory::new();
4290
4291        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4292        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4293
4294        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4295            .expect("fixture fits in Decimal");
4296        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4297            .expect("fixture fits in Decimal");
4298
4299        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4300            .unwrap();
4301
4302        // Should have exactly one AAPL position remaining
4303        let aapl_positions: Vec<_> = inv
4304            .positions
4305            .iter()
4306            .filter(|p| p.units.currency.as_ref() == "AAPL")
4307            .collect();
4308        assert_eq!(aapl_positions.len(), 1);
4309        assert_eq!(aapl_positions[0].units.number, dec!(15));
4310    }
4311
4312    #[test]
4313    fn test_average_uneven_lots() {
4314        // Weighted average with unequal lot sizes
4315        let mut inv = Inventory::new();
4316
4317        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4318        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4319
4320        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1))
4321            .expect("fixture fits in Decimal");
4322        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4323            .expect("fixture fits in Decimal");
4324
4325        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
4326        let result = inv
4327            .reduce(
4328                &Amount::new(dec!(-10), "AAPL"),
4329                None,
4330                BookingMethod::Average,
4331            )
4332            .unwrap();
4333
4334        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
4335    }
4336
4337    // --- NONE ---
4338
4339    #[test]
4340    fn test_none_booking_with_cost_positions() {
4341        // NONE booking should work even when positions have costs
4342        let mut inv = Inventory::new();
4343
4344        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4345        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4346            .expect("fixture fits in Decimal");
4347
4348        let result = inv
4349            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
4350            .unwrap();
4351
4352        assert_eq!(inv.units("AAPL"), dec!(5));
4353        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
4354        assert!(result.cost_basis.is_some());
4355        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4356    }
4357
4358    #[test]
4359    fn test_none_booking_short_cover() {
4360        // Covering a short position with NONE booking
4361        let mut inv = Inventory::new();
4362        inv.add(Position::simple(Amount::new(dec!(-100), "USD")))
4363            .expect("fixture fits in Decimal");
4364
4365        // Positive amount should reduce the negative position
4366        let result = inv
4367            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
4368            .unwrap();
4369
4370        assert_eq!(inv.units("USD"), dec!(-70));
4371        assert!(!result.matched.is_empty());
4372    }
4373
4374    #[test]
4375    fn test_none_booking_empty_inventory_augments() {
4376        // NONE booking on empty inventory should augment
4377        let mut inv = Inventory::new();
4378
4379        let result = inv
4380            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
4381            .unwrap();
4382
4383        assert_eq!(inv.units("USD"), dec!(50));
4384        assert!(result.matched.is_empty()); // Augmentation, not reduction
4385    }
4386
4387    // --- Cross-method: short positions ---
4388
4389    #[test]
4390    fn test_fifo_short_position_cover() {
4391        // FIFO: cover short positions (oldest short first)
4392        let mut inv = Inventory::new();
4393
4394        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4395        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4396
4397        inv.add(Position::with_cost(
4398            Amount::new(dec!(-10), "AAPL"),
4399            cost_old,
4400        ))
4401        .expect("fixture fits in Decimal");
4402        inv.add(Position::with_cost(
4403            Amount::new(dec!(-10), "AAPL"),
4404            cost_new,
4405        ))
4406        .expect("fixture fits in Decimal");
4407
4408        // Cover 5 shares — FIFO should pick oldest short (cost 100)
4409        let result = inv
4410            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
4411            .unwrap();
4412
4413        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
4414        assert_eq!(inv.units("AAPL"), dec!(-15));
4415    }
4416
4417    #[test]
4418    fn test_lifo_short_position_cover() {
4419        // LIFO: cover short positions (newest short first)
4420        let mut inv = Inventory::new();
4421
4422        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4423        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4424
4425        inv.add(Position::with_cost(
4426            Amount::new(dec!(-10), "AAPL"),
4427            cost_old,
4428        ))
4429        .expect("fixture fits in Decimal");
4430        inv.add(Position::with_cost(
4431            Amount::new(dec!(-10), "AAPL"),
4432            cost_new,
4433        ))
4434        .expect("fixture fits in Decimal");
4435
4436        // Cover 5 shares — LIFO should pick newest short (cost 200)
4437        let result = inv
4438            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
4439            .unwrap();
4440
4441        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4442        assert_eq!(inv.units("AAPL"), dec!(-15));
4443    }
4444
4445    // === AccountedBookingError Display tests ===
4446    //
4447    // These tests pin the canonical user-facing wording for every variant
4448    // of `AccountedBookingError`. The whole point of unifying booking-error
4449    // Display into `rustledger-core` (#750) is that there's a single source
4450    // of truth — and a single source of truth with no tests is one refactor
4451    // away from drifting again, which is exactly the failure mode that
4452    // produced #748. Any change to the Display strings below will break
4453    // these tests, forcing the author to consciously re-check pta-standards
4454    // conformance assertions and downstream user tooling.
4455
4456    // =========================================================================
4457    // Regression test for issue #875 / beancount#889
4458    //
4459    // When a sell-without-cost-spec leaves a negative simple position in the
4460    // inventory, a subsequent augmentation WITH a cost spec should NOT be
4461    // misclassified as a reduction. `is_reduced_by` must only consider
4462    // cost-bearing positions when the incoming posting has a cost spec.
4463    // =========================================================================
4464
4465    #[test]
4466    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
4467        // Regression test for issue #875 / beancount#889.
4468        //
4469        // Scenario:
4470        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
4471        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
4472        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
4473        //
4474        // Before fix: is_reduced_by saw the -25 HOOG simple position and
4475        // incorrectly reported that +50 HOOG would reduce the inventory.
4476        let mut inv = Inventory::new();
4477
4478        // Step 1: buy 100 HOOG with cost
4479        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
4480        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost))
4481            .expect("fixture fits in Decimal");
4482
4483        // Step 2: sell 25 HOOG without cost spec (simple position)
4484        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")))
4485            .expect("fixture fits in Decimal");
4486
4487        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
4488        let buy_units = Amount::new(dec!(50), "HOOG");
4489
4490        // With has_cost_spec=true, only cost-bearing positions should be
4491        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
4492        // incoming 50 HOOG -> same sign -> NOT a reduction.
4493        assert!(
4494            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
4495            "augmentation with cost spec should NOT be treated as reduction \
4496             when only a simple (no-cost) position has opposite sign"
4497        );
4498
4499        // With AllPositions, all positions are considered,
4500        // including the -25 HOOG simple position -> IS a reduction.
4501        assert!(
4502            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
4503            "without cost spec filter, the -25 HOOG simple position \
4504             should cause is_reduced_by to return true"
4505        );
4506    }
4507
4508    #[test]
4509    fn is_booking_reduction_gates_on_method_cost_and_sign() {
4510        // A cost-bearing long position.
4511        let mut inv = Inventory::new();
4512        inv.add(Position::with_cost(
4513            Amount::new(dec!(10), "AAPL"),
4514            Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
4515        ))
4516        .expect("fixture fits in Decimal");
4517
4518        let sell = Amount::new(dec!(-5), "AAPL"); // opposite sign of the held lot
4519        let buy = Amount::new(dec!(5), "AAPL"); // same sign
4520        let spec = CostSpec::empty(); // only spec *presence* (is_some) matters here
4521
4522        // Opposite-sign units carrying a cost spec under a lot-matching method
4523        // is the one combination that reduces.
4524        assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
4525        // NONE never reduces — every posting accumulates (#1182).
4526        assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
4527        // No cost spec -> augmentation.
4528        assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
4529        // Same sign as the held lot -> augmentation.
4530        assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
4531    }
4532
4533    #[test]
4534    fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
4535        let mut bank = Inventory::new();
4536        bank.add(Position::simple(Amount::new(dec!(10), "USD")))
4537            .expect("fixture fits in Decimal");
4538        let mut checking = Inventory::new(); // sub-account: included
4539        checking
4540            .add(Position::simple(Amount::new(dec!(40), "USD")))
4541            .expect("fixture fits in Decimal");
4542        let mut alias = Inventory::new(); // prefix sibling: excluded
4543        alias
4544            .add(Position::simple(Amount::new(dec!(99), "USD")))
4545            .expect("fixture fits in Decimal");
4546
4547        let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
4548        map.insert(Account::from("Assets:Bank"), bank);
4549        map.insert(Account::from("Assets:Bank:Checking"), checking);
4550        map.insert(Account::from("Assets:BankAlias"), alias);
4551
4552        let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"))
4553            .expect("fixture fits in Decimal");
4554        assert_eq!(
4555            total,
4556            dec!(50),
4557            "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
4558        );
4559    }
4560
4561    #[test]
4562    fn test_accounted_error_display_insufficient_units() {
4563        let err = BookingError::InsufficientUnits {
4564            currency: "AAPL".into(),
4565            requested: dec!(15),
4566            available: dec!(10),
4567        }
4568        .with_account("Assets:Stock".into());
4569        let rendered = format!("{err}");
4570
4571        // Pinned by pta-standards `reduction-exceeds-inventory`
4572        // (`error_contains: ["not enough"]`). See #748 / #749.
4573        assert!(
4574            rendered.contains("not enough"),
4575            "must contain 'not enough' (pta-standards): {rendered}"
4576        );
4577        assert!(
4578            rendered.contains("Assets:Stock"),
4579            "must contain account name: {rendered}"
4580        );
4581        assert!(
4582            rendered.contains("15") && rendered.contains("10"),
4583            "must contain requested and available amounts: {rendered}"
4584        );
4585    }
4586
4587    #[test]
4588    fn test_accounted_error_display_no_matching_lot() {
4589        let err = BookingError::NoMatchingLot {
4590            currency: "AAPL".into(),
4591            cost_spec: CostSpec::empty(),
4592        }
4593        .with_account("Assets:Stock".into());
4594        let rendered = format!("{err}");
4595
4596        assert!(
4597            rendered.contains("No matching lot"),
4598            "must contain 'No matching lot': {rendered}"
4599        );
4600        assert!(
4601            rendered.contains("AAPL"),
4602            "must contain currency: {rendered}"
4603        );
4604        assert!(
4605            rendered.contains("Assets:Stock"),
4606            "must contain account name: {rendered}"
4607        );
4608    }
4609
4610    #[test]
4611    fn test_accounted_error_display_ambiguous_match() {
4612        let err = BookingError::AmbiguousMatch {
4613            num_matches: 3,
4614            currency: "AAPL".into(),
4615        }
4616        .with_account("Assets:Stock".into());
4617        let rendered = format!("{err}");
4618
4619        assert!(
4620            rendered.contains("Ambiguous"),
4621            "must contain 'Ambiguous': {rendered}"
4622        );
4623        assert!(
4624            rendered.contains("AAPL"),
4625            "must contain currency: {rendered}"
4626        );
4627        assert!(
4628            rendered.contains("Assets:Stock"),
4629            "must contain account name: {rendered}"
4630        );
4631        assert!(
4632            rendered.contains('3'),
4633            "must contain match count: {rendered}"
4634        );
4635    }
4636
4637    #[test]
4638    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
4639        // CurrencyMismatch is semantically a specialization of NoMatchingLot
4640        // (there is no lot for the given currency in this inventory) and the
4641        // canonical Display collapses them into the same user-facing phrasing
4642        // so that consumers filtering on E4001 don't need to special-case it.
4643        // This variant is defensive — no `Inventory::reduce` path currently
4644        // emits it — but we still pin its rendering in case a future emission
4645        // site is added.
4646        let err = BookingError::CurrencyMismatch {
4647            expected: "USD".into(),
4648            got: "EUR".into(),
4649        }
4650        .with_account("Assets:Cash".into());
4651        let rendered = format!("{err}");
4652
4653        assert!(
4654            rendered.contains("No matching lot"),
4655            "CurrencyMismatch must render as 'No matching lot' for E4001 \
4656             consistency: {rendered}"
4657        );
4658        assert!(
4659            rendered.contains("EUR"),
4660            "must contain the mismatched (got) currency: {rendered}"
4661        );
4662        assert!(
4663            rendered.contains("Assets:Cash"),
4664            "must contain account name: {rendered}"
4665        );
4666    }
4667
4668    /// `sign_index` must agree with a scan after EVERY mutation path, not
4669    /// just the ones a given test happens to follow with an
4670    /// `is_reduced_by` call.
4671    ///
4672    /// `is_reduced_by`'s own `debug_assert` compares the two on every call,
4673    /// which covers the whole suite — but only where something calls it.
4674    /// This walks the mutations that can move a lot between buckets and
4675    /// checks after each: a cost-less merge that flips a lot's sign by adding
4676    /// through zero, a reduction that takes a lot to exactly zero (removing
4677    /// it), and a partial reduction that leaves it. The comparison is
4678    /// explicit rather than leaning on the assertion, so it holds in release
4679    /// builds too.
4680    #[test]
4681    fn the_sign_index_tracks_every_mutation_path() {
4682        let usd = Amount::new(dec!(1), "USD");
4683        let aapl = Amount::new(dec!(1), "AAPL");
4684        let check = |inv: &Inventory, label: &str| {
4685            // The incrementally maintained counts must equal what a fresh
4686            // rebuild computes. This is the invariant that matters, and it is
4687            // strictly stronger than "the answers agree": an empty cache
4688            // still ANSWERS correctly, because `is_reduced_by` falls back to
4689            // the scan — so a path that quietly stopped maintaining the counts
4690            // would restore the O(lots) cost with every test still green.
4691            // Comparing against a rebuild catches that, and catches a broken
4692            // rebuild too, since the two are independent code.
4693            //
4694            // Zero-count entries are filtered from both sides: `units_cache`
4695            // keeps a currency's entry for its running total after the last
4696            // lot closes, which a rebuild has no reason to create.
4697            let counts_of = |inv: &Inventory| {
4698                inv.units_cache
4699                    .iter()
4700                    .filter(|(_, stats)| stats.counts != SignCounts::default())
4701                    .map(|(currency, stats)| (currency.as_str().to_string(), stats.counts))
4702                    .collect::<std::collections::BTreeMap<_, _>>()
4703            };
4704            let mut rebuilt = inv.clone();
4705            rebuilt.rebuild_index();
4706            assert_eq!(
4707                counts_of(inv),
4708                counts_of(&rebuilt),
4709                "the incrementally maintained sign counts diverged from a \
4710                 fresh rebuild after {label}",
4711            );
4712            for units in [&usd, &aapl] {
4713                for signed in [
4714                    units.clone(),
4715                    Amount::new(-units.number, units.currency.clone()),
4716                ] {
4717                    for scope in [
4718                        ReductionScope::AllPositions,
4719                        ReductionScope::CostBearingOnly,
4720                    ] {
4721                        assert_eq!(
4722                            inv.is_reduced_by(&signed, scope),
4723                            inv.is_reduced_by_scan(&signed, scope),
4724                            "the sign counts disagree with a scan after {label} \
4725                         for {signed:?} / {scope:?}",
4726                        );
4727                    }
4728                }
4729            }
4730        };
4731
4732        let mut inv = Inventory::new();
4733        check(&inv, "empty");
4734
4735        // Cost-less lot, then a merge that takes it negative through zero.
4736        inv.add(Position::simple(Amount::new(dec!(3), "USD")))
4737            .expect("fits");
4738        check(&inv, "one simple lot");
4739        inv.add(Position::simple(Amount::new(dec!(-8), "USD")))
4740            .expect("fits");
4741        check(&inv, "simple lot flipped negative by merge");
4742        inv.add(Position::simple(Amount::new(dec!(8), "USD")))
4743            .expect("fits");
4744        check(&inv, "simple lot flipped back positive");
4745
4746        // Cost-bearing lots, then reductions that partially and fully drain.
4747        let cost = Cost::new(dec!(100), "USD");
4748        inv.add(Position::with_cost(
4749            Amount::new(dec!(10), "AAPL"),
4750            cost.clone(),
4751        ))
4752        .expect("fits");
4753        check(&inv, "one cost-bearing lot");
4754
4755        inv.reduce(
4756            &Amount::new(dec!(-4), "AAPL"),
4757            Some(&CostSpec::default()),
4758            BookingMethod::Fifo,
4759        )
4760        .expect("partial reduction");
4761        check(&inv, "partially reduced lot");
4762
4763        inv.reduce(
4764            &Amount::new(dec!(-6), "AAPL"),
4765            Some(&CostSpec::default()),
4766            BookingMethod::Fifo,
4767        )
4768        .expect("full reduction");
4769        check(&inv, "fully drained lot");
4770
4771        // STRICT with a single matching lot takes the OTHER commit path —
4772        // `commit_from_lot`, which maintains the caches incrementally instead
4773        // of rebuilding. A FIFO-only test leaves it completely uncovered.
4774        let mut strict = Inventory::new();
4775        strict
4776            .add(Position::with_cost(
4777                Amount::new(dec!(10), "AAPL"),
4778                cost.clone(),
4779            ))
4780            .expect("fits");
4781        check(&strict, "strict: one lot");
4782        strict
4783            .reduce(
4784                &Amount::new(dec!(-4), "AAPL"),
4785                Some(&CostSpec::default()),
4786                BookingMethod::Strict,
4787            )
4788            .expect("partial strict reduction");
4789        check(&strict, "strict: partially reduced");
4790        strict
4791            .reduce(
4792                &Amount::new(dec!(-6), "AAPL"),
4793                Some(&CostSpec::default()),
4794                BookingMethod::Strict,
4795            )
4796            .expect("draining strict reduction");
4797        check(&strict, "strict: lot drained and removed");
4798        assert!(
4799            strict.positions.is_empty(),
4800            "the fixture must actually remove the lot, or the removal path is \
4801             untested",
4802        );
4803
4804        // A SHORT lot covered to exactly zero. This is the only shape where a
4805        // reduction changes a lot's bucket: `is_sign_positive` answers TRUE
4806        // for zero, so a negative lot reaching 0 moves from the negative
4807        // bucket to the positive one in the instant before it is removed.
4808        // Skipping the reclassify then decrements the wrong bucket and leaves
4809        // the index claiming a short lot that no longer exists. A long lot
4810        // cannot show this — it is capped at zero from above and never leaves
4811        // the positive bucket.
4812        let mut short = Inventory::new();
4813        short
4814            .add(Position::with_cost(
4815                Amount::new(dec!(-5), "AAPL"),
4816                Cost::new(dec!(100), "USD"),
4817            ))
4818            .expect("fits");
4819        check(&short, "short: one negative lot");
4820        short
4821            .reduce(
4822                &Amount::new(dec!(5), "AAPL"),
4823                Some(&CostSpec::default()),
4824                BookingMethod::Strict,
4825            )
4826            .expect("covering the short");
4827        check(&short, "short: covered to zero and removed");
4828        assert!(
4829            short.positions.is_empty(),
4830            "the short must actually close, or the bucket flip is untested",
4831        );
4832
4833        // And a rebuild must land on the same state as the incremental path.
4834        // Captured from an inventory whose last mutation was `commit_from_lot`
4835        // (no rebuild), so the two are genuinely independent here.
4836        let mut incremental_inv = Inventory::new();
4837        incremental_inv
4838            .add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4839            .expect("fits");
4840        incremental_inv
4841            .reduce(
4842                &Amount::new(dec!(-4), "AAPL"),
4843                Some(&CostSpec::default()),
4844                BookingMethod::Strict,
4845            )
4846            .expect("partial strict reduction");
4847        let incremental = incremental_inv.units_cache.clone();
4848        assert!(!incremental.is_empty(), "fixture holds a lot");
4849        incremental_inv.rebuild_index();
4850        assert_eq!(
4851            incremental, incremental_inv.units_cache,
4852            "the incrementally maintained index must equal a fresh rebuild",
4853        );
4854    }
4855
4856    /// An inventory whose caches were never built still answers
4857    /// `is_reduced_by` correctly.
4858    ///
4859    /// The caches are `#[serde(skip)]`. Deserialization rebuilds them, but
4860    /// `positions_mut` hands out the position vector directly, so an
4861    /// inventory CAN hold lots with an empty cache. Reading the counts then
4862    /// would answer "not a reduction" for an inventory that plainly holds a
4863    /// matching lot — booking a sale as a purchase and duplicating the lot,
4864    /// which is the #875-class bug this predicate exists to prevent.
4865    ///
4866    /// So the unbuilt case falls back to the scan.
4867    ///
4868    /// The cache is cleared DIRECTLY here. It used to be reached through
4869    /// `positions_mut`, which handed out the position vector and left the
4870    /// caches describing the old contents — that accessor is gone, and its
4871    /// replacement `modify_positions` rebuilds them, so no public API produces
4872    /// this state any more. The fallback stays because `units_cache` is
4873    /// `#[serde(skip)]` and answering "not a reduction" for an inventory that
4874    /// plainly holds a matching lot is the unsafe direction; this constructs
4875    /// the state the only way left, and says so.
4876    #[test]
4877    fn an_unbuilt_cache_falls_back_to_the_scan_rather_than_answering_no() {
4878        let mut inv = Inventory::new();
4879        inv.add(Position::with_cost(
4880            Amount::new(dec!(10), "AAPL"),
4881            Cost::new(dec!(100), "USD"),
4882        ))
4883        .expect("fits");
4884        inv.units_cache.clear();
4885        assert!(
4886            inv.units_cache.is_empty(),
4887            "the fixture must reach `is_reduced_by` with an unbuilt cache, or \
4888             it is testing the fast path instead",
4889        );
4890
4891        assert!(
4892            inv.is_reduced_by(
4893                &Amount::new(dec!(-4), "AAPL"),
4894                ReductionScope::CostBearingOnly
4895            ),
4896            "a sale against a held lot must be seen as a reduction even with \
4897             no cache built",
4898        );
4899        assert!(
4900            !inv.is_reduced_by(
4901                &Amount::new(dec!(4), "AAPL"),
4902                ReductionScope::CostBearingOnly
4903            ),
4904            "a purchase in the same direction is still an augmentation",
4905        );
4906
4907        // And once the caches are built, the answers are unchanged.
4908        inv.rebuild_index();
4909        assert!(!inv.units_cache.is_empty(), "rebuild populates the cache");
4910        assert!(inv.is_reduced_by(
4911            &Amount::new(dec!(-4), "AAPL"),
4912            ReductionScope::CostBearingOnly
4913        ));
4914        assert!(!inv.is_reduced_by(
4915            &Amount::new(dec!(4), "AAPL"),
4916            ReductionScope::CostBearingOnly
4917        ));
4918    }
4919
4920    /// A cost-less lot sitting after a removed one keeps working.
4921    ///
4922    /// Removal used to shift every later position down one, so `simple_index`
4923    /// — which stores positions BY INDEX — had to be repaired to follow the
4924    /// shift. With tombstones nothing moves, so the entry must be left exactly
4925    /// where it is; repairing it now would point `add`'s merge at the wrong
4926    /// slot. Same test, opposite mechanism, and the consequence it guards is
4927    /// unchanged: a later cost-less deposit must MERGE rather than duplicate.
4928    ///
4929    /// Nothing else in the suite covers it: it needs a cost-bearing lot and a
4930    /// cost-less lot in the same inventory, with the cost-bearing one removed
4931    /// first, and inventories in most tests hold only one kind.
4932    #[test]
4933    fn removing_a_lot_repairs_the_index_of_a_later_cost_less_lot() {
4934        let mut inv = Inventory::new();
4935        // Index 0: cost-bearing. Index 1: cost-less, so `simple_index` says 1.
4936        inv.add(Position::with_cost(
4937            Amount::new(dec!(10), "AAPL"),
4938            Cost::new(dec!(100), "USD"),
4939        ))
4940        .expect("fits");
4941        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
4942            .expect("fits");
4943        assert_eq!(
4944            inv.units_cache
4945                .get(&crate::Currency::new("USD"))
4946                .and_then(|s| s.simple_slot),
4947            Some(1),
4948            "fixture must put the cost-less lot second, or the shift is untested",
4949        );
4950
4951        // Drain the cost-bearing lot. STRICT takes the single-lot commit path,
4952        // which tombstones the slot in place.
4953        inv.reduce(
4954            &Amount::new(dec!(-10), "AAPL"),
4955            Some(&CostSpec::default()),
4956            BookingMethod::Strict,
4957        )
4958        .expect("drains the lot");
4959
4960        assert_eq!(
4961            inv.units_cache
4962                .get(&crate::Currency::new("USD"))
4963                .and_then(|s| s.simple_slot),
4964            Some(1),
4965            "the cost-less lot did not move, so its stored slot must not change",
4966        );
4967
4968        // The consequence a stale index actually has: this must MERGE into the
4969        // existing lot, not append a second one.
4970        inv.add(Position::simple(Amount::new(dec!(25), "USD")))
4971            .expect("fits");
4972        assert_eq!(
4973            inv.positions().count(),
4974            1,
4975            "a stale simple_index appends a duplicate cost-less lot instead of \
4976             merging",
4977        );
4978        assert_eq!(inv.units("USD"), dec!(75));
4979    }
4980
4981    /// Reducing a COST-LESS lot to zero removes it, and `simple_index` points
4982    /// at exactly that lot — so the entry must go, not just shift.
4983    #[test]
4984    fn removing_a_cost_less_lot_drops_its_index_entry() {
4985        let mut inv = Inventory::new();
4986        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
4987            .expect("fits");
4988        assert_eq!(
4989            inv.units_cache
4990                .get(&crate::Currency::new("USD"))
4991                .and_then(|s| s.simple_slot),
4992            Some(0)
4993        );
4994
4995        // An empty spec matches a cost-less lot (`matches_cost_spec`:
4996        // `(None, true) => true`), so STRICT selects it and drains it.
4997        inv.reduce(
4998            &Amount::new(dec!(-50), "USD"),
4999            Some(&CostSpec::default()),
5000            BookingMethod::Strict,
5001        )
5002        .expect("drains the cost-less lot");
5003
5004        assert!(inv.positions().next().is_none(), "the lot is gone");
5005        assert_eq!(
5006            inv.units_cache
5007                .get(&crate::Currency::new("USD"))
5008                .and_then(|s| s.simple_slot),
5009            None,
5010            "a stale entry points at a removed lot; the next cost-less add \
5011             indexes past the end",
5012        );
5013
5014        // The consequence: this must not panic and must create a fresh lot.
5015        inv.add(Position::simple(Amount::new(dec!(20), "USD")))
5016            .expect("fits");
5017        assert_eq!(inv.units("USD"), dec!(20));
5018    }
5019
5020    /// Every index `iter_slots` yields must address, through `Index`, the very
5021    /// position it was yielded with.
5022    ///
5023    /// This is trivially true while the backing store is dense — `iter_slots`
5024    /// is `iter().enumerate()` — and it is the whole reason that method
5025    /// exists. The reduction paths collect indices from it and hand them back
5026    /// through `Index`/`IndexMut` to mutate the lot they selected. If the
5027    /// store ever becomes sparse (tombstoned lots, so a cost-keyed index can
5028    /// survive removals) and `iter_slots` keeps counting from zero instead of
5029    /// reporting real slots, every reduction after the first hole mutates the
5030    /// WRONG LOT — silently, with correct-looking totals.
5031    ///
5032    /// So this pins the contract rather than the current implementation.
5033    #[test]
5034    fn iter_slots_yields_indices_that_address_their_own_position() {
5035        let mut inv = Inventory::new();
5036        for units in [dec!(10), dec!(20), dec!(30)] {
5037            inv.add(Position::with_cost(
5038                Amount::new(units, "AAPL"),
5039                Cost::new(units * dec!(10), "USD"),
5040            ))
5041            .expect("fits");
5042        }
5043        // Two lots IDENTICAL by value. `add` never merges cost-bearing lots —
5044        // it keeps them separate to match Python — so this is an ordinary
5045        // inventory, and it is the shape that makes the assertion below
5046        // meaningful: with only distinct lots, comparing by value cannot tell
5047        // "the right slot" from "a slot holding an equal position".
5048        for _ in 0..2 {
5049            inv.add(Position::with_cost(
5050                Amount::new(dec!(7), "AAPL"),
5051                Cost::new(dec!(70), "USD"),
5052            ))
5053            .expect("fits");
5054        }
5055        inv.add(Position::simple(Amount::new(dec!(99), "USD")))
5056            .expect("fits");
5057
5058        let mut seen = 0;
5059        for (slot, position) in inv.positions.iter_slots() {
5060            // Pointer identity, not `assert_eq!`. `Position: PartialEq`, so a
5061            // value comparison passes when a wrong index happens to land on an
5062            // equal lot — exactly what the duplicate pair above arranges.
5063            // Review catch on #2065.
5064            assert!(
5065                std::ptr::eq(std::ptr::from_ref(&inv.positions[slot]), position),
5066                "slot {slot} addresses a different position than the one it \
5067                 was yielded with",
5068            );
5069            seen += 1;
5070        }
5071        assert_eq!(
5072            seen,
5073            inv.positions().count(),
5074            "iter_slots must visit every live position",
5075        );
5076        assert_eq!(seen, 6, "fixture must hold six lots, two of them equal");
5077    }
5078
5079    /// The point of tombstoning: removing a lot must not renumber the lots
5080    /// after it.
5081    ///
5082    /// Shifting was what made a cost-keyed match index impossible — every
5083    /// removal invalidated every later index. This is the property the whole
5084    /// sparse backing exists to provide, so it is asserted directly rather
5085    /// than inferred from something downstream.
5086    #[test]
5087    fn a_removal_does_not_renumber_the_lots_after_it() {
5088        let mut inv = Inventory::new();
5089        for units in [dec!(10), dec!(20), dec!(30)] {
5090            inv.add(Position::with_cost(
5091                Amount::new(units, "AAPL"),
5092                Cost::new(units * dec!(10), "USD"),
5093            ))
5094            .expect("fits");
5095        }
5096        let before: Vec<usize> = inv.positions.iter_slots().map(|(slot, _)| slot).collect();
5097        assert_eq!(before, vec![0, 1, 2], "fixture must fill three slots");
5098
5099        // Drain the MIDDLE lot, so a shift would move the one after it.
5100        inv.reduce(
5101            &Amount::new(dec!(-20), "AAPL"),
5102            Some(
5103                &CostSpec::empty()
5104                    .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5105                    .with_currency("USD"),
5106            ),
5107            BookingMethod::Strict,
5108        )
5109        .expect("drains the middle lot");
5110
5111        let after: Vec<(usize, Decimal)> = inv
5112            .positions
5113            .iter_slots()
5114            .map(|(slot, p)| (slot, p.units.number))
5115            .collect();
5116        assert_eq!(
5117            after,
5118            vec![(0, dec!(10)), (2, dec!(30))],
5119            "the surviving lots must keep the slots they had; slot 1 is now a \
5120             tombstone and slot 2 must NOT have become slot 1",
5121        );
5122        assert_eq!(inv.positions.len(), 2, "two live lots");
5123        assert_eq!(inv.positions.slot_count(), 3, "three slots, one dead");
5124    }
5125
5126    /// Tombstones must not pile up without bound.
5127    ///
5128    /// Without compaction a long-lived account accumulates one dead slot per
5129    /// closed lot, and every scan walks all of them — matching would degrade
5130    /// toward "every lot this account ever held", which is far worse than the
5131    /// shifting it replaced.
5132    ///
5133    /// `reduce` compacts on its own whenever no undo log is open, which is the
5134    /// case for every caller except a transaction in flight. This drives it
5135    /// exactly as the Late validator does — no engine, no explicit call —
5136    /// because that is the caller that would otherwise grow a dead slot per
5137    /// closed lot for the life of the ledger.
5138    #[test]
5139    fn tombstones_are_compacted_rather_than_accumulating() {
5140        let mut inv = Inventory::new();
5141        for i in 0..50u32 {
5142            let cost = Decimal::from(100 + i);
5143            inv.add(Position::with_cost(
5144                Amount::new(dec!(1), "AAPL"),
5145                Cost::new(cost, "USD"),
5146            ))
5147            .expect("fits");
5148            inv.reduce(
5149                &Amount::new(dec!(-1), "AAPL"),
5150                Some(
5151                    &CostSpec::empty()
5152                        .with_number(crate::CostNumber::PerUnit { value: cost })
5153                        .with_currency("USD"),
5154                ),
5155                BookingMethod::Strict,
5156            )
5157            .expect("drains it again");
5158        }
5159        assert_eq!(inv.positions.len(), 0, "every lot was closed");
5160        assert!(
5161            inv.positions.slot_count() <= 4,
5162            "50 open-and-close cycles left {} slots; compaction is not running, \
5163             and every later scan pays for all of them",
5164            inv.positions.slot_count(),
5165        );
5166    }
5167
5168    /// Draining a lot must take it out of the cost index.
5169    ///
5170    /// A stale entry is not merely wasteful: the slot it names is a tombstone,
5171    /// and it would be handed to the reduction path as a candidate. The lookup
5172    /// tolerates that by design, but the index still has to be maintained —
5173    /// otherwise the lists grow without bound as lots close, and the whole
5174    /// point of the index erodes. Asserted directly, because the tolerant
5175    /// lookup means no behavioral test can see the difference.
5176    #[test]
5177    fn draining_a_lot_removes_it_from_the_cost_index() {
5178        let spec = || {
5179            CostSpec::empty()
5180                .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5181                .with_currency("USD")
5182        };
5183        let mut inv = Inventory::new();
5184        inv.add(Position::with_cost(
5185            Amount::new(dec!(10), "AAPL"),
5186            Cost::new(dec!(100), "USD"),
5187        ))
5188        .expect("fits");
5189        assert_eq!(inv.cost_index.len(), 1, "the lot is indexed");
5190
5191        inv.reduce(
5192            &Amount::new(dec!(-10), "AAPL"),
5193            Some(&spec()),
5194            BookingMethod::Strict,
5195        )
5196        .expect("drains the lot");
5197
5198        assert!(
5199            inv.cost_index.is_empty(),
5200            "the drained lot is still indexed: {:?}",
5201            inv.cost_index,
5202        );
5203
5204        // And the same cost can be re-used afterwards without tripping over
5205        // the old slot — the case a stale entry would reach.
5206        inv.add(Position::with_cost(
5207            Amount::new(dec!(5), "AAPL"),
5208            Cost::new(dec!(100), "USD"),
5209        ))
5210        .expect("fits");
5211        let result = inv
5212            .reduce(
5213                &Amount::new(dec!(-5), "AAPL"),
5214                Some(&spec()),
5215                BookingMethod::Strict,
5216            )
5217            .expect("re-buying at the same cost and selling must work");
5218        assert_eq!(result.matched.len(), 1);
5219        assert_eq!(inv.positions.len(), 0);
5220    }
5221
5222    /// `modify_positions` hands over a DENSE vector and rebuilds every cache.
5223    ///
5224    /// It replaced `positions_mut`, which returned the backing vector
5225    /// directly. Two promises to keep: the closure must never see tombstones
5226    /// (the sparse backing is an implementation detail), and everything
5227    /// derived — units totals, the cost-less merge index, the cost index and
5228    /// the sign counts — must describe what the closure left, not what was
5229    /// there before. The old accessor kept none of that, which its own docs
5230    /// warned about.
5231    #[test]
5232    fn modify_positions_hands_over_a_dense_vector_and_rebuilds_the_caches() {
5233        let mut inv = Inventory::new();
5234        for units in [dec!(10), dec!(20)] {
5235            inv.add(Position::with_cost(
5236                Amount::new(units, "AAPL"),
5237                Cost::new(units * dec!(10), "USD"),
5238            ))
5239            .expect("fits");
5240        }
5241        // Drain the first lot so a tombstone exists before the handover.
5242        inv.reduce(
5243            &Amount::new(dec!(-10), "AAPL"),
5244            Some(
5245                &CostSpec::empty()
5246                    .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5247                    .with_currency("USD"),
5248            ),
5249            BookingMethod::Strict,
5250        )
5251        .expect("drains the first lot");
5252        assert_eq!(inv.positions.slot_count(), 2, "one live lot, one tombstone");
5253
5254        inv.modify_positions(|positions| {
5255            assert_eq!(
5256                positions.len(),
5257                1,
5258                "the closure must see only live lots; tombstones are ours, not \
5259                 the caller's",
5260            );
5261            positions.push(Position::simple(Amount::new(dec!(5), "USD")));
5262        });
5263
5264        // Every derived structure now describes what the closure left.
5265        assert_eq!(inv.units("USD"), dec!(5), "units_cache rebuilt");
5266        assert_eq!(inv.units("AAPL"), dec!(20));
5267        assert_eq!(inv.positions.len(), 2);
5268
5269        // simple_index rebuilt: a further cost-less add MERGES.
5270        inv.add(Position::simple(Amount::new(dec!(2), "USD")))
5271            .expect("fits");
5272        assert_eq!(inv.positions.len(), 2, "merged rather than appended");
5273        assert_eq!(inv.units("USD"), dec!(7));
5274
5275        // cost_index rebuilt: the surviving lot is still findable by its cost.
5276        inv.reduce(
5277            &Amount::new(dec!(-20), "AAPL"),
5278            Some(
5279                &CostSpec::empty()
5280                    .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5281                    .with_currency("USD"),
5282            ),
5283            BookingMethod::Strict,
5284        )
5285        .expect("the surviving lot is still reachable through the cost index");
5286        assert_eq!(inv.units("AAPL"), dec!(0));
5287    }
5288
5289    /// A shared snapshot must not carry the cost index.
5290    ///
5291    /// `Inventory` derives `Clone`, and BQL clones a shared running balance
5292    /// ONCE PER OUTPUT ROW — the executor says so directly above the call.
5293    /// The shared backing makes the positions O(1) to clone, which is what
5294    /// #1086 needed; a per-inventory map holding roughly an entry per distinct
5295    /// cost would put O(lots) straight back into every one of those clones and
5296    /// undo it.
5297    ///
5298    /// Nothing else in the suite would notice: the index is invisible in
5299    /// results, and no instruction profile here runs BQL. So it is asserted
5300    /// directly, on the representation.
5301    #[test]
5302    fn a_shared_snapshot_carries_no_cost_index() {
5303        let mut shared = Inventory::new_shared();
5304        for units in [dec!(10), dec!(20), dec!(30)] {
5305            shared
5306                .add(Position::with_cost(
5307                    Amount::new(units, "AAPL"),
5308                    Cost::new(units * dec!(10), "USD"),
5309                ))
5310                .expect("fits");
5311        }
5312        shared.rebuild_index();
5313        assert!(
5314            shared.cost_index.is_empty(),
5315            "a shared snapshot built an index of {} entries; every per-row \
5316             clone now pays for it",
5317            shared.cost_index.len(),
5318        );
5319
5320        // The owned backing — the one that books — still gets it.
5321        let mut owned = Inventory::new();
5322        for units in [dec!(10), dec!(20), dec!(30)] {
5323            owned
5324                .add(Position::with_cost(
5325                    Amount::new(units, "AAPL"),
5326                    Cost::new(units * dec!(10), "USD"),
5327                ))
5328                .expect("fits");
5329        }
5330        assert_eq!(
5331            owned.cost_index.len(),
5332            3,
5333            "the owned backing must still index its lots, or the fast path is \
5334             dead everywhere",
5335        );
5336
5337        // And a snapshot still books CORRECTLY, by scanning: an inventory with
5338        // no index must never answer "no matching lot" for a lot it holds.
5339        let result = shared
5340            .reduce(
5341                &Amount::new(dec!(-20), "AAPL"),
5342                Some(
5343                    &CostSpec::empty()
5344                        .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5345                        .with_currency("USD"),
5346                ),
5347                BookingMethod::Strict,
5348            )
5349            .expect("a snapshot with no cost index must fall back to scanning");
5350        assert_eq!(result.matched.len(), 1);
5351    }
5352
5353    /// Tombstones must not reach the wire, and a round trip must come back
5354    /// dense.
5355    ///
5356    /// Every other round-trip test builds its inventory with `add` alone, so
5357    /// none of them has a tombstone in it — the sparse backing was entirely
5358    /// untested through serde. It matters twice over: the wire format is
5359    /// pinned by downstream snapshots, and a leaked `null` would both break
5360    /// them and deserialize into a lot that does not exist.
5361    #[test]
5362    fn a_drained_lot_does_not_reach_the_wire() {
5363        let mut inv = Inventory::new();
5364        for units in [dec!(10), dec!(20)] {
5365            inv.add(Position::with_cost(
5366                Amount::new(units, "AAPL"),
5367                Cost::new(units * dec!(10), "USD"),
5368            ))
5369            .expect("fits");
5370        }
5371        inv.reduce(
5372            &Amount::new(dec!(-10), "AAPL"),
5373            Some(
5374                &CostSpec::empty()
5375                    .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5376                    .with_currency("USD"),
5377            ),
5378            BookingMethod::Strict,
5379        )
5380        .expect("drains the first lot");
5381        assert_eq!(
5382            inv.positions.slot_count(),
5383            2,
5384            "the fixture must actually hold a tombstone, or this proves nothing",
5385        );
5386        assert_eq!(inv.positions.len(), 1, "one live lot");
5387
5388        let json = serde_json::to_string(&inv).expect("serializes");
5389        // Check the positions ARRAY, not the whole string: `Cost`'s optional
5390        // `date` and `label` serialize as `null` legitimately, so a bare
5391        // "contains null" search reports a leak that is not there.
5392        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
5393        let wire_positions = parsed
5394            .get("positions")
5395            .and_then(serde_json::Value::as_array)
5396            .expect("positions is an array");
5397        assert_eq!(
5398            wire_positions.len(),
5399            1,
5400            "the wire must carry only the live lot, not the tombstone: {json}",
5401        );
5402        assert!(
5403            !wire_positions.iter().any(serde_json::Value::is_null),
5404            "a tombstone leaked onto the wire as a null element: {json}",
5405        );
5406
5407        let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
5408        assert_eq!(
5409            round_tripped.positions.slot_count(),
5410            1,
5411            "the round trip must come back dense, not carrying the hole",
5412        );
5413        assert_eq!(round_tripped.positions.len(), 1);
5414        assert_eq!(round_tripped.units("AAPL"), dec!(20));
5415        assert_eq!(
5416            round_tripped
5417                .positions()
5418                .next()
5419                .expect("one lot")
5420                .units
5421                .number,
5422            dec!(20),
5423        );
5424
5425        // The rebuilt caches must work: the surviving lot is still bookable.
5426        let mut round_tripped = round_tripped;
5427        round_tripped
5428            .reduce(
5429                &Amount::new(dec!(-20), "AAPL"),
5430                Some(
5431                    &CostSpec::empty()
5432                        .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5433                        .with_currency("USD"),
5434                ),
5435                BookingMethod::Strict,
5436            )
5437            .expect("the deserialized lot is reachable");
5438        assert_eq!(round_tripped.units("AAPL"), dec!(0));
5439    }
5440}