Skip to main content

btctax_core/
optimize.rs

1//! Sub-project C — rate-aware optimizer. ASSIGNS lots to disposals (specific identification);
2//! it does NOT advise whether to sell/hold (no investment advice — §C scope). Minimizes B's
3//! federal `total_federal_tax_attributable` over feasible per-disposal `LotSelection`s, within the
4//! §1.1012-1(j) identification boundary (adequate ID by the time of sale; no compliant post-hoc).
5//! Deterministic (NFR4) + exact (NFR5): BTreeMap/sorted iteration, Decimal/i64 only, no float.
6//!
7//! ## §1091 wash sale (C.5) — crypto is currently EXEMPT.
8//! §1091 disallows a loss only on "stock or securities"; the IRS treats convertible virtual currency
9//! as property, not a security (Notice 2014-21 — the on-point property characterization; property
10//! treatment is reaffirmed generally by Rev. Rul. 2023-14, which itself concerns staking income under
11//! §61 rather than the §1091 "stock or securities" scope), and **no statute extending §1091
12//! to crypto has been enacted** (only recurring Greenbook/legislative proposals). The optimizer
13//! therefore selects loss lots **freely** — loss harvesting is unconstrained, and a chosen loss is
14//! never disallowed/deferred here. Form 1099-DA box 1i reports wash-sale disallowances only for
15//! assets that are in fact securities — not a change to crypto. **MONITOR for enactment**; if §1091
16//! is extended, loss-lot selection must add a disallowance rule and this note must change in lockstep
17//! (FOLLOWUPS.md — C.5).
18use crate::conventions::{is_long_term, one_year_after, Sat, TaxDate, Usd, TRANSITION_DATE};
19use crate::event::{DisposeKind, LedgerEvent, LotPick};
20use crate::identity::{EventId, LotId, SourceRef, WalletId};
21use crate::price::{fmv_of, PriceProvider};
22use crate::project::fold::{fold, pools_before, state_as_of};
23use crate::project::pools::{pool_key, PoolKey};
24use crate::project::resolve::{resolve, Eff, Op};
25use crate::project::{
26    disposal_compliance, evaluate_disposal, project, CandidateDisposal, ComplianceStatus,
27    DisposalCompliance, EvaluateError, ProjectionConfig,
28};
29use crate::state::{Blocker, LedgerState, Lot};
30use crate::tax::{compute_tax_year, MarginalRates, TaxOutcome, TaxProfile, TaxResult, TaxTables};
31use std::collections::{BTreeMap, BTreeSet};
32
33/// The `accept`-gate verdict for one disposal (computed in core; enforced by the CLI, Task 10).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Persistability {
36    /// The selection's made-date is at/before the sale → §A.5(b) `Contemporaneous`; persist freely.
37    /// Reached only when the lot is NOT 2027+ broker-held — the broker envelope is tested first
38    /// (own-books contemporaneous ID is insufficient for a 2027+ broker lot; see `persistability`).
39    ContemporaneousNow,
40    /// Already-executed (made-date after the sale) but within the own-books envelope → persist ONLY
41    /// behind the narrow contemporaneous-ID attestation (→ `AttestedRecording`).
42    NeedsAttestation,
43    /// 2027+ broker-held: own-books is insufficient; C may NEVER persist (no attestation can cure it).
44    ForbiddenBroker2027,
45}
46
47/// One disposal's line in a Mode-1 proposal.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DisposalProposal {
50    pub disposal: EventId,
51    pub wallet: WalletId,
52    pub date: TaxDate,
53    pub current_selection: Vec<LotPick>, // lots the CURRENT projection consumes (baseline)
54    pub proposed_selection: Vec<LotPick>, // the optimizer's tax-minimizing pick
55    pub status: ComplianceStatus,        // overlay-aware (may be AttestedRecording, Task 5)
56    pub persistable: Persistability,
57}
58
59/// Why a proposal is only APPROXIMATE (not a proven global minimum). Carried OUT of core (core has no
60/// logger) so the CLI can log the cap/why and the renderer can show the banner. Plain counts only →
61/// deterministic + serde/Eq-friendly (R0-C1/C3 fold).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ApproxReason {
64    /// The cartesian product of per-group candidate lists exceeded `MAX_COMBOS`; the baseline-seeded
65    /// coordinate-descent fallback ran (a LOCAL optimum — disclosed, and never worse than baseline).
66    ComboCapExceeded { combos: usize, cap: usize },
67    /// ≥1 contended same-wallet pool could not be JOINTLY enumerated within the bound; its disposals
68    /// fell back to per-disposal-independent generation (a cross-period reassignment optimum may be
69    /// missed — R0-C3). `contended` = number of disposals in the un-enumerated contention group(s).
70    ContentionUnenumerated {
71        contended: usize,
72        combos: usize,
73        cap: usize,
74    },
75    /// ≥1 target disposal's available pool exceeded `LOT_ENUM_BOUND`, so `candidate_selections`
76    /// returned a deterministic but INCOMPLETE heuristic SUBSET of that pool's vertices (not the full
77    /// vertex enumeration) — the result over that pool is therefore NOT a proven global minimum
78    /// (R2-C1). Common in practice (weekly-DCA / active-trading pools with > 12 lots). `lots` = the
79    /// largest heuristic pool's lot count; `bound` = `LOT_ENUM_BOUND`. Baseline-seeded, so `delta ≤ 0`
80    /// still holds — the disclosure corrects the false "proven optimum" claim, not the pick's safety.
81    PoolHeuristic { lots: usize, bound: usize },
82}
83
84/// Mode-1 proposal: what-if by default (running this binds NOTHING — §C.2).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct OptimizeProposal {
87    pub year: i32,
88    pub baseline_tax: Usd, // total_federal_tax_attributable under current identification
89    pub optimized_tax: Usd, // under the proposed selections
90    pub delta: Usd, // optimized − baseline — ALWAYS ≤ 0 (baseline-seeded search; never worsens)
91    pub per_disposal: Vec<DisposalProposal>,
92    pub marginal_rates: MarginalRates,
93    /// `false` ⇔ the vertex set was **FULLY enumerated AND exhaustively scored** — i.e. EVERY target
94    /// disposal's pool was ≤ `LOT_ENUM_BOUND` (complete vertex enumeration, NOT a heuristic subset —
95    /// R2-C1), the overall `product` was ≤ `MAX_COMBOS` (exhaustive, not coordinate-descent), AND every
96    /// contended pool was jointly enumerated. ONLY then is the result the PROVEN global minimum over the
97    /// vertex space. `true` ⇔ ANY of those failed (a disclosed LOCAL / under-enumerated / heuristic-pool
98    /// result) — the renderer MUST print the "APPROXIMATE — not a guaranteed global minimum" banner and
99    /// the CLI MUST log `approx_reason` (R0-C1/C3, R2-C1). NEVER render `optimized_tax` as "the optimum"
100    /// when this is `true`.
101    pub approximate: bool,
102    pub approx_reason: Option<ApproxReason>,
103}
104
105/// Mode-2 (pre-trade consultation) request — a hypothetical sale NOT in the ledger.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ConsultRequest {
108    pub sell_sat: Sat,
109    pub wallet: WalletId,
110    pub at: TaxDate,
111    pub proceeds: Option<Usd>, // required when no dataset price exists for `at` (future dates)
112    pub kind: DisposeKind,
113}
114
115/// §C.3 ST→LT crossover timing insight (tax decision-support; NOT a hold/sell recommendation).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct TimingInsight {
118    pub st_sat_in_selection: Sat, // sats in the best selection that are short-term as of `at`
119    pub latest_crossover: TaxDate, // the last date any of those lots becomes long-term
120    pub tax_if_sold_long_term: Usd, // same lots, scored as if sold on/after `latest_crossover`
121    pub saving_if_waited: Usd,    // total_now − tax_if_sold_long_term (≥ 0)
122}
123
124/// Mode-2 read-only what-if result.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ConsultReport {
127    pub req: ConsultRequest,
128    pub proposed_selection: Vec<LotPick>,
129    pub st_gain: Usd,
130    pub lt_gain: Usd,
131    /// The WHOLE-YEAR crypto-attributable federal tax WITH this hypothetical sale (real disposals +
132    /// hyp, vs no crypto). On a year that ALREADY has real disposals this OVER-states the hypothetical's
133    /// own effect — read `marginal_tax` for the sale's incremental cost. Kept + relabeled, not removed.
134    pub total_federal_tax_attributable: Usd,
135    /// The EXACT marginal federal tax THIS hypothetical sale causes = `withhyp.total − baseline.total`
136    /// (baseline = the real projection WITHOUT the synthetic sale). The shared no-crypto term cancels,
137    /// so this is the sale's own effect — the figure to headline (the identity the old report violated).
138    pub marginal_tax: Usd,
139    pub timing: Option<TimingInsight>,
140    /// `true` when the available pool exceeded `LOT_ENUM_BOUND` (12) and `candidate_selections`
141    /// returned a deterministic but INCOMPLETE heuristic subset — the proposed selection may not be
142    /// the true tax-minimum for large pools. `false` for pools ≤ 12 (complete vertex enumeration).
143    /// Symmetric with Mode-1's `PoolHeuristic` banner; carried out of core so the CLI renderer can
144    /// show the disclosure note (core has no renderer — C-M2).
145    pub approximate: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum OptimizeError {
150    /// B refuses to compute the year (any Hard blocker anywhere, or missing profile/table) — I6.
151    YearNotComputable(Blocker),
152    /// A synthetic consult disposal needs `--proceeds` (no dataset price for `at`), etc.
153    Evaluate(EvaluateError),
154    /// Mode 1: the year has no method-honoring disposals to optimize.
155    NoDisposals,
156    /// Mode 2: the wallet has no lots available to sell at `at`.
157    NoLots,
158    /// The requested year is pre-2025 — a restatement of a closed year, not an optimization (M7).
159    PreTransitionYear(i32),
160}
161
162// ── Task 2 — holistic year scorer ────────────────────────────────────────────────────────────────
163
164/// Fold the canonical timeline with `assignment`'s per-disposal selections injected (overriding any
165/// persisted selection for those events), WITHOUT mutating the ledger. Clone-fold-discard (mirrors
166/// `evaluate.rs`'s `resolve` → inject `selections` → `fold` path): `events`/`prices`/`config` are
167/// borrowed read-only, `resolve` yields an owned `Resolution` we mutate, and the resulting
168/// `LedgerState` is the caller's to read then discard. Iteration is over a `BTreeMap` (NFR4).
169fn fold_with(
170    events: &[LedgerEvent],
171    prices: &dyn PriceProvider,
172    config: &ProjectionConfig,
173    assignment: &BTreeMap<EventId, Vec<LotPick>>,
174) -> LedgerState {
175    let mut res = resolve(events, prices, config);
176    for (disposal, picks) in assignment {
177        // Override any persisted/default selection for this disposal; BTreeMap order = deterministic.
178        res.selections.insert(disposal.clone(), picks.clone());
179    }
180    fold(res, prices, config)
181}
182
183/// R0-M1 precondition check: does every injected pick-set conserve its disposal's principal?
184///
185/// `fold_with` injects straight into `res.selections`, bypassing `resolve`'s `Σ == principal` guard;
186/// and the fold's `consume_picks` hardcodes `shortfall = 0` while `selection_feasible` checks only
187/// per-lot availability — NOT the sum. A non-conserving assignment would therefore under-consume
188/// *silently* (no blocker) → a falsely-low score. So we fold the BASELINE once (no injection), map
189/// each disposal → `Σ legs.sat` (its principal), and require every injected entry's `Σ picks.sat` to
190/// match. A disposal id absent from the baseline, or a zero principal, ⇒ fail.
191fn assignment_conserves_principal(
192    events: &[LedgerEvent],
193    prices: &dyn PriceProvider,
194    config: &ProjectionConfig,
195    assignment: &BTreeMap<EventId, Vec<LotPick>>,
196) -> bool {
197    let base = project(events, prices, config);
198    let mut principal: BTreeMap<EventId, Sat> = BTreeMap::new();
199    for d in &base.disposals {
200        let sum: Sat = d.legs.iter().map(|l| l.sat).sum();
201        principal.insert(d.event.clone(), sum);
202    }
203    assignment.iter().all(|(disposal, picks)| {
204        let picked: Sat = picks.iter().map(|p| p.sat).sum();
205        matches!(principal.get(disposal), Some(&p) if p > 0 && p == picked)
206    })
207}
208
209/// Holistic score: B's federal `TaxOutcome` for `year` under `assignment`. Inject the per-disposal
210/// selections, **fold once**, run `compute_tax_year`. Side-effect-free (clone-fold-discard),
211/// deterministic (NFR4), exact (NFR5 — all dollars come straight from B; C never re-rounds).
212///
213/// An infeasible selection (cross-disposal contention / over-draw / unknown or cross-wallet lot)
214/// folds to a hard `LotSelectionInvalid` (the fold's `consume_principal` maps the selection error)
215/// → `compute_tax_year` refuses with `NotComputable` — the caller skips that combination.
216///
217/// **PRECONDITION — principal conservation (R0-M1).** Each injected pick-set MUST satisfy
218/// `Σ LotPick.sat == the disposal's principal sat`. `fold_with` injects straight into
219/// `res.selections`, bypassing `resolve`'s `Σ == principal` guard, and the fold does NOT enforce the
220/// sum (`consume_picks` hardcodes `shortfall = 0`; `selection_feasible` checks only per-lot
221/// availability), so a NON-conserving assignment under-consumes *silently* → a falsely-low score.
222/// `optimize_year`/`consult_sale` generators always conserve, but this fn is `pub` for reuse + KATs,
223/// so it `debug_assert!`s the sum against the per-disposal principal (looked up from a baseline fold).
224/// The check runs only under `debug_assert!` (zero release cost).
225pub fn score_assignment(
226    events: &[LedgerEvent],
227    prices: &dyn PriceProvider,
228    config: &ProjectionConfig,
229    year: i32,
230    profile: Option<&TaxProfile>,
231    tables: &dyn TaxTables,
232    assignment: &BTreeMap<EventId, Vec<LotPick>>,
233) -> TaxOutcome {
234    // R0-M1 guard: in debug builds, assert each injected pick-set conserves the disposal's principal.
235    debug_assert!(
236        assignment_conserves_principal(events, prices, config, assignment),
237        "score_assignment: injected assignment violates Σpicks == principal (R0-M1)"
238    );
239    let state = fold_with(events, prices, config, assignment);
240    compute_tax_year(events, &state, year, profile, tables)
241}
242
243// ── Task 3 — candidate generation (available-lots pre-pass + bounded-complete vertex enumeration) ──
244
245/// At/below this many available lots a disposal's candidate set is the COMPLETE vertex enumeration
246/// (every whole-lot subset + ≤1 partial). Above it, `candidate_selections` returns a deterministic but
247/// INCOMPLETE heuristic subset and signals that fact (R2-C1). `2^12 = 4096` masks is the per-pool
248/// ceiling — well inside `MAX_COMBOS`.
249const LOT_ENUM_BOUND: usize = 12;
250
251/// The lots available in `wallet` JUST BEFORE `disposal` — the true at-disposal pool the engine's
252/// `selection_feasible` checks a specific-lot pick against, computed by a clone-fold of the timeline up to
253/// (but NOT including) the disposal (NFR4: deterministic; no fold modification). Post-2025 → the disposal's
254/// own wallet pool (§1.1012-1(j) per-wallet); pre-2025 → the universal pool. Returns lots with
255/// `remaining_sat > 0`, sorted by `lot_id` (a total order). `pub` so the interactive editor can build its
256/// select-lots candidate rows from the SAME pool the CLI validates against, instead of the post-default
257/// residue (walkthrough J9 review): membership + at-disposal amounts are then correct, so the form can never
258/// offer a pick the engine rejects. Works for any target event kind (disposal / removal / self-transfer).
259///
260/// **Delegates the fold to `fold::pools_before`** (which mirrors the real fold's canonical + transition
261/// ordering AND fires the §7.4 boundary seed at the correct point). This is the fix for the Task-3 review
262/// IMPORTANT: the previous "truncate-then-refold" never crossed `TRANSITION_DATE` when the target disposal
263/// was the chronologically-first 2025 timeline event, so the re-fold never seeded and returned the
264/// UN-seeded Universal residue — correct under Path A (residue relocates by wallet, lot_ids preserved) but
265/// WRONG under Path B (the seed DISCARDS the residue and installs allocation lots with different
266/// lot_ids/basis). `pools_before` reuses the real `seed_transition`, so the pool matches the live fold for
267/// pre-2025 disposals (Universal), Path-A post-2025 (per-wallet relocated), and Path-B post-2025 (seeded
268/// lots) — for the FIRST 2025 disposal and every later one. R0-I1 canonical ordering is preserved inside
269/// `pools_before`; an absent disposal still yields an empty `Vec` (existence checked here first).
270pub fn available_lots_before(
271    events: &[LedgerEvent],
272    prices: &dyn PriceProvider,
273    config: &ProjectionConfig,
274    disposal: &EventId,
275    date: TaxDate,
276    wallet: &WalletId,
277) -> Vec<Lot> {
278    available_lots_before_with(
279        events,
280        prices,
281        config,
282        disposal,
283        date,
284        wallet,
285        &BTreeMap::new(),
286    )
287}
288
289/// As `available_lots_before`, but with `injected` per-disposal selections folded in FIRST (so an earlier
290/// group member's chosen candidate is consumed before this disposal's pool is read). This is the engine of
291/// the nested joint enumeration of a contention group (R0-C3): each later disposal draws from the pool left
292/// by the prior members' CHOSEN picks, not the baseline — which is exactly what recovers cross-period
293/// reassignment optima (a lot ST at `D1`'s date but LT at `D2`'s date). An empty `injected` map degenerates
294/// to the plain baseline pre-pass. Deterministic: `injected` is a `BTreeMap` (NFR4).
295fn available_lots_before_with(
296    events: &[LedgerEvent],
297    prices: &dyn PriceProvider,
298    config: &ProjectionConfig,
299    disposal: &EventId,
300    date: TaxDate,
301    wallet: &WalletId,
302    injected: &BTreeMap<EventId, Vec<LotPick>>,
303) -> Vec<Lot> {
304    let mut res = resolve(events, prices, config);
305    // "not found ⇒ empty" contract (ordering-independent existence check; `pools_before` would otherwise
306    // fold the whole timeline for a missing target).
307    if !res.timeline.iter().any(|e| &e.id == disposal) {
308        return Vec::new();
309    }
310    // Override the consumption of the already-chosen group members (BTreeMap order = deterministic).
311    for (d, picks) in injected {
312        res.selections.insert(d.clone(), picks.clone());
313    }
314    // Pool state just before the disposal, with the transition seed already applied at the correct
315    // boundary (Path A drain / Path B seed) — matches the real fold under both paths (Task-3 IMPORTANT).
316    let pools = pools_before(res, prices, config, disposal);
317    let want = pool_key(date, wallet); // post-2025 → Wallet(wallet); pre-2025 → Universal
318    let mut lots: Vec<Lot> = pools
319        .pools
320        .into_values()
321        .flatten()
322        .filter(|l| l.remaining_sat > 0 && pool_key(date, &l.wallet) == want)
323        .collect();
324    lots.sort_by(|a, b| a.lot_id.cmp(&b.lot_id)); // total order (NFR4)
325    lots
326}
327
328/// All principal-conserving vertex selections of `need` sats over `lots` (same pool): every whole-lot
329/// subset summing to `need`, plus each strict subset (Σ < `need`) extended by ONE partial lot to reach
330/// `need`. Deduped + sorted (NFR4). On pools `> LOT_ENUM_BOUND`, a deterministic heuristic set instead.
331///
332/// **Returns `(candidates, heuristic)` (R2-C1).** `heuristic == false` ⇔ the COMPLETE vertex set was
333/// enumerated (`lots.len() <= LOT_ENUM_BOUND`); `heuristic == true` ⇔ the pool exceeded the bound and
334/// only a deterministic INCOMPLETE subset was returned — the caller (`optimize_year`, Task 4) MUST then
335/// flag the proposal `approximate = true, PoolHeuristic { .. }` (a heuristic-pool result is not a proven
336/// global minimum). Without this signal a single `> 12`-lot pool would score `approximate = false` and
337/// render as "the optimum" — the headline-forbidden false-global claim.
338fn candidate_selections(lots: &[Lot], need: Sat) -> (Vec<Vec<LotPick>>, bool) {
339    let heuristic = lots.len() > LOT_ENUM_BOUND; // R2-C1: did we take the incomplete branch?
340    let mut out: std::collections::BTreeSet<Vec<LotPick>> = std::collections::BTreeSet::new();
341    // canonical key for a pick-set: sort the picks by lot id (so the BTreeSet dedups by identity).
342    let canon = |mut v: Vec<LotPick>| {
343        v.sort_by(|a, b| a.lot.cmp(&b.lot));
344        v
345    };
346
347    if lots.len() <= LOT_ENUM_BOUND {
348        // complete vertex enumeration over 2^n subsets (n ≤ 12)
349        for mask in 0u32..(1u32 << lots.len()) {
350            let mut whole: Vec<LotPick> = Vec::new();
351            let mut sum: Sat = 0;
352            for (i, l) in lots.iter().enumerate() {
353                if mask & (1 << i) != 0 {
354                    whole.push(LotPick {
355                        lot: l.lot_id.clone(),
356                        sat: l.remaining_sat,
357                    });
358                    sum += l.remaining_sat;
359                }
360            }
361            if sum == need {
362                out.insert(canon(whole)); // whole-lot vertex
363            } else if sum < need {
364                let short = need - sum; // top up with ONE partial lot not in the mask
365                for (i, l) in lots.iter().enumerate() {
366                    if mask & (1 << i) == 0 && l.remaining_sat >= short {
367                        let mut v = whole.clone();
368                        v.push(LotPick {
369                            lot: l.lot_id.clone(),
370                            sat: short,
371                        });
372                        out.insert(canon(v));
373                    }
374                }
375            }
376        }
377    } else {
378        // deterministic heuristic generators (greedy-fill in a given lot order)
379        let fill = |order: Vec<usize>| -> Option<Vec<LotPick>> {
380            let mut v = Vec::new();
381            let mut rem = need;
382            for i in order {
383                if rem <= 0 {
384                    break;
385                }
386                let take = rem.min(lots[i].remaining_sat);
387                if take > 0 {
388                    v.push(LotPick {
389                        lot: lots[i].lot_id.clone(),
390                        sat: take,
391                    });
392                    rem -= take;
393                }
394            }
395            (rem == 0).then(|| canon(v))
396        };
397        let by = |key: &dyn Fn(&Lot, &Lot) -> std::cmp::Ordering| {
398            let mut ix: Vec<usize> = (0..lots.len()).collect();
399            ix.sort_by(|&a, &b| key(&lots[a], &lots[b]));
400            ix
401        };
402        use std::cmp::Ordering;
403        let hifo = |a: &Lot, b: &Lot| {
404            (b.usd_basis * Usd::from(a.remaining_sat))
405                .cmp(&(a.usd_basis * Usd::from(b.remaining_sat)))
406                .then(a.acquired_at.cmp(&b.acquired_at))
407                .then(a.lot_id.cmp(&b.lot_id))
408        };
409        let fifo = |a: &Lot, b: &Lot| {
410            a.acquired_at
411                .cmp(&b.acquired_at)
412                .then(a.lot_id.cmp(&b.lot_id))
413        };
414        let lifo = |a: &Lot, b: &Lot| {
415            b.acquired_at
416                .cmp(&a.acquired_at)
417                .then(b.lot_id.cmp(&a.lot_id))
418        };
419        let lt_first = |a: &Lot, b: &Lot| {
420            a.gain_hp_start()
421                .cmp(&b.gain_hp_start())
422                .then(a.lot_id.cmp(&b.lot_id))
423        };
424        for k in [
425            &hifo as &dyn Fn(&Lot, &Lot) -> Ordering,
426            &fifo,
427            &lifo,
428            &lt_first,
429        ] {
430            if let Some(v) = fill(by(k)) {
431                out.insert(v);
432            }
433        }
434        for lead in 0..lots.len() {
435            // per-lot lead, then HIFO-fill
436            let mut order = vec![lead];
437            order.extend(by(&hifo).into_iter().filter(|&i| i != lead));
438            if let Some(v) = fill(order) {
439                out.insert(v);
440            }
441        }
442    }
443    (out.into_iter().collect(), heuristic) // (sorted Vec — NFR4, heuristic-branch flag — R2-C1)
444}
445
446// ── Task 5 — pure compliance / persistability helpers (consumed by `optimize_year`'s row build) ────
447//
448// These pure functions are owned by Task 5 (with their dedicated KAT suite); Task 4's per-disposal row
449// construction depends on them (R0-C2), so they live here and are exercised end-to-end by the Mode-1
450// optimality/refusal KATs. `optimize_year` judges a PROPOSED pick by its OWN made-date — a standing order
451// never rescues a divergent post-hoc cherry-pick (§1.1012-1(j)).
452
453/// §A.5 custody → envelope (R2-M5): `Exchange` = broker (own-books insufficient 2027+); `SelfCustody` =
454/// own-books, all years, no relief ever needed.
455fn is_broker(w: &WalletId) -> bool {
456    matches!(w, WalletId::Exchange { .. })
457}
458
459/// The §C.2 `accept`-gate verdict for one disposal (computed in core; enforced by the CLI, Task 10).
460///
461/// **The 2027+ broker envelope is AUTHORITATIVE and tested FIRST**, taking precedence over the
462/// `made ≤ sale` (contemporaneous) lever: for a 2027+ broker-held lot own-books identification is
463/// INSUFFICIENT (Notices 2025-07/2026-20 own-books relief ENDS in 2026; broker-communicated
464/// specific-ID is required 2027+), so even a genuinely contemporaneous (`made ≤ sale`) own-books pick
465/// CANNOT rescue it — it is NEVER `ContemporaneousNow`. This mirrors `proposed_compliance_status`,
466/// which likewise judges 2027+ broker → `NonCompliant` ahead of the contemporaneous branch (the two
467/// functions MUST agree — see the FOLLOWUPS Task-4 asymmetry, now closed). Order:
468/// - 2027+ broker-held (ANY timing, incl. made ≤ sale) → `ForbiddenBroker2027` (NEVER persist; no
469///   attestation can cure it).
470/// - else made-date ≤ sale → §A.5(b) `Contemporaneous` lever; persist freely (`ContemporaneousNow`).
471/// - else already-executed (made > sale; self-custody any year, or broker-held 2025–2026) →
472///   `NeedsAttestation`.
473pub fn persistability(
474    wallet: &WalletId,
475    sale_date: TaxDate,
476    selection_made: TaxDate,
477) -> Persistability {
478    // §1.1012-1(j): the 2027+ broker envelope is authoritative — it precedes the contemporaneous
479    // branch because own-books ID (even made ≤ sale) is insufficient for a 2027+ broker lot. So such a
480    // lot is categorically ForbiddenBroker2027, never ContemporaneousNow (mirrors proposed_compliance_status).
481    if is_broker(wallet) && sale_date.year() >= 2027 {
482        Persistability::ForbiddenBroker2027
483    } else if selection_made <= sale_date {
484        Persistability::ContemporaneousNow
485    } else {
486        Persistability::NeedsAttestation
487    }
488}
489
490/// R0-C2: compliance status of a PROPOSED pick, judged by ITS OWN timeliness. The proposed pick is a
491/// would-be `LotSelection` made at `made` (= proposal/now), NOT a persisted selection in `events`;
492/// `disposal_compliance(events, …)` would skip the selection branch and let a standing order RESCUE a
493/// divergent post-hoc cherry-pick as `StandingOrder` (FORBIDDEN — §1.1012-1(j)). So this judges it directly:
494/// - `proposed == current` (no change): keep `baseline_status` — adopting an identical pick binds nothing
495///   new (`accept` skips it). **This is the ONLY path that may report `StandingOrder`.**
496/// - diverges: 2027+ broker → `NonCompliant`; else `made ≤ sale` → `Contemporaneous`; else (post-hoc) →
497///   `NonCompliant`. The overlay may later upgrade a post-hoc `NonCompliant` → `AttestedRecording` ONLY
498///   when attested AND within the own-books envelope AND unchanged.
499pub fn proposed_compliance_status(
500    wallet: &WalletId,
501    sale_date: TaxDate,
502    made: TaxDate,
503    proposed: &[LotPick],
504    current: &[LotPick],
505    baseline_status: &ComplianceStatus,
506) -> ComplianceStatus {
507    if proposed == current {
508        return baseline_status.clone(); // no divergence ⇒ the real, already-established status stands
509    }
510    if is_broker(wallet) && sale_date.year() >= 2027 {
511        return ComplianceStatus::NonCompliant;
512    }
513    if made <= sale_date {
514        ComplianceStatus::Contemporaneous
515    } else {
516        ComplianceStatus::NonCompliant // divergent post-hoc cherry-pick — NEVER StandingOrder
517    }
518}
519
520/// Upgrade A's per-disposal compliance for the `optimize` surface: a `NonCompliant` disposal is upgraded
521/// to `AttestedRecording` IFF (a) it is in `attested`, (b) it is in `unchanged` (the PROPOSED pick equals
522/// the in-force persisted-and-attested selection — R2-I1: an attestation binds only the exact attested
523/// selection, never a divergent re-run pick), AND (c) it is within the envelope (NOT 2027+ broker-held).
524/// `StandingOrder`/`Contemporaneous` rows are left untouched; a non-attested OR divergent post-hoc
525/// selection stays `NonCompliant` (the conservative direction).
526pub fn compliance_overlay(
527    base: &[DisposalCompliance],
528    attested: &BTreeSet<EventId>,
529    unchanged: &BTreeSet<EventId>,
530) -> Vec<DisposalCompliance> {
531    base.iter()
532        .map(|c| {
533            let upgrade = matches!(c.status, ComplianceStatus::NonCompliant)
534                && attested.contains(&c.disposal)
535                && unchanged.contains(&c.disposal) // R2-I1: attestation binds only the attested pick
536                && !(is_broker(&c.wallet) && c.date.year() >= 2027);
537            let mut out = c.clone();
538            if upgrade {
539                out.status = ComplianceStatus::AttestedRecording;
540            }
541            out
542        })
543        .collect()
544}
545
546// ── Task 4 — contention grouping + joint candidate enumeration (R0-C3) ─────────────────────────────
547
548/// Per-group joint-enumeration ceiling (≤ `MAX_COMBOS`). Beyond it a contended group falls back to
549/// per-disposal-independent generation and the proposal is flagged `ContentionUnenumerated`.
550const GROUP_COMBO_BOUND: usize = 4_096;
551
552/// Partition the year's `targets` into contention groups: disposals sharing one `PoolKey::Wallet` pool
553/// whose `available_lots_before` (baseline) lot-id sets OVERLAP are one group; a non-overlapping disposal
554/// is its own singleton group. Deterministic (NFR4): union-find over pre-sorted `targets` (EventId order),
555/// members ascending, groups ordered by their first member's EventId.
556fn contention_groups(
557    events: &[LedgerEvent],
558    prices: &dyn PriceProvider,
559    config: &ProjectionConfig,
560    targets: &[(EventId, WalletId, TaxDate, Sat)],
561) -> Vec<Vec<usize>> {
562    let n = targets.len();
563    // Per-target (pool key, available-lot-id set) under the baseline consumption.
564    let infos: Vec<(PoolKey, BTreeSet<LotId>)> = targets
565        .iter()
566        .map(|(id, wallet, date, _need)| {
567            let lots = available_lots_before(events, prices, config, id, *date, wallet);
568            let ids: BTreeSet<LotId> = lots.into_iter().map(|l| l.lot_id).collect();
569            (pool_key(*date, wallet), ids)
570        })
571        .collect();
572
573    fn find(parent: &mut [usize], mut x: usize) -> usize {
574        while parent[x] != x {
575            parent[x] = parent[parent[x]]; // path-halving
576            x = parent[x];
577        }
578        x
579    }
580    let mut parent: Vec<usize> = (0..n).collect();
581    for i in 0..n {
582        for j in (i + 1)..n {
583            // Same wallet pool AND overlapping available lots ⇒ contended (reassignment may help).
584            if infos[i].0 == infos[j].0 && !infos[i].1.is_disjoint(&infos[j].1) {
585                let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
586                if ri != rj {
587                    parent[ri] = rj;
588                }
589            }
590        }
591    }
592    // Group by root; members pushed in ascending index order (== ascending EventId, targets pre-sorted).
593    let mut by_root: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
594    for i in 0..n {
595        let r = find(&mut parent, i);
596        by_root.entry(r).or_default().push(i);
597    }
598    let mut groups: Vec<Vec<usize>> = by_root.into_values().collect();
599    groups.sort_by(|a, b| targets[a[0]].0.cmp(&targets[b[0]].0));
600    groups
601}
602
603/// One alias for the (verbose) joint-enumeration return: the per-sequence partial assignments plus the
604/// largest heuristic pool's lot-count (`Some` ⇒ a nested `candidate_selections` took the `> LOT_ENUM_BOUND`
605/// INCOMPLETE branch, so the caller flags `PoolHeuristic`).
606type JointMaps = (Vec<BTreeMap<EventId, Vec<LotPick>>>, Option<usize>);
607
608/// Per-disposal proposal-row metadata threaded between the status pass and the final `DisposalProposal`
609/// build: `(disposal, wallet, sale date, current picks, proposed picks)`.
610type RowMeta = (EventId, WalletId, TaxDate, Vec<LotPick>, Vec<LotPick>);
611
612/// Joint candidate assignments for ONE contention group, generated by NESTING `candidate_selections` in
613/// canonical (time, then EventId) order: the earliest disposal draws from the pre-group pool; each later
614/// disposal draws from the pool LEFT by the prior members' chosen candidate (via
615/// `available_lots_before_with`, which re-folds with those picks injected). This recovers cross-period
616/// reassignment optima the independent per-disposal product cannot reach. Returns `None` when the joint
617/// count would exceed `GROUP_COMBO_BOUND` (→ caller flags `ContentionUnenumerated`). Deterministic: the
618/// returned maps are sorted + deduped.
619fn group_candidate_assignments(
620    events: &[LedgerEvent],
621    prices: &dyn PriceProvider,
622    config: &ProjectionConfig,
623    group: &[(EventId, WalletId, TaxDate, Sat)],
624) -> Option<JointMaps> {
625    // TIME order (then EventId) so the pool evolution is correct: earlier disposals consume first; later
626    // disposals see the remaining pool PLUS lots acquired between them.
627    let mut order: Vec<usize> = (0..group.len()).collect();
628    order.sort_by(|&a, &b| {
629        group[a]
630            .2
631            .cmp(&group[b].2)
632            .then(group[a].0.cmp(&group[b].0))
633    });
634
635    let mut partials: Vec<BTreeMap<EventId, Vec<LotPick>>> = vec![BTreeMap::new()];
636    let mut max_heur: Option<usize> = None;
637    for &mi in &order {
638        let (id, wallet, date, need) = &group[mi];
639        let mut next: Vec<BTreeMap<EventId, Vec<LotPick>>> = Vec::new();
640        for partial in &partials {
641            let lots =
642                available_lots_before_with(events, prices, config, id, *date, wallet, partial);
643            let (cands, heuristic) = candidate_selections(&lots, *need);
644            if heuristic {
645                max_heur = Some(max_heur.map_or(lots.len(), |m| m.max(lots.len())));
646            }
647            for c in cands {
648                let mut p2 = partial.clone();
649                p2.insert(id.clone(), c);
650                next.push(p2);
651                if next.len() > GROUP_COMBO_BOUND {
652                    return None; // beyond the per-group ceiling → caller flags ContentionUnenumerated
653                }
654            }
655        }
656        partials = next;
657    }
658    partials.sort();
659    partials.dedup();
660    Some((partials, max_heur))
661}
662
663/// Independent per-disposal candidate maps for a group that could NOT be jointly enumerated within the
664/// bound: each member uses its own `available_lots_before` (baseline) candidates; the group's list is the
665/// cartesian product of members' independent lists. Misses cross-period reassignment, hence the caller's
666/// `ContentionUnenumerated` flag — but stays baseline-safe (the baseline picks are still seeded separately).
667fn independent_group_maps(
668    events: &[LedgerEvent],
669    prices: &dyn PriceProvider,
670    config: &ProjectionConfig,
671    baseline_state: &LedgerState,
672    group: &[(EventId, WalletId, TaxDate, Sat)],
673) -> Vec<BTreeMap<EventId, Vec<LotPick>>> {
674    let mut maps: Vec<BTreeMap<EventId, Vec<LotPick>>> = vec![BTreeMap::new()];
675    for (id, wallet, date, need) in group {
676        let lots = available_lots_before(events, prices, config, id, *date, wallet);
677        let (mut cands, _heuristic) = candidate_selections(&lots, *need);
678        if cands.is_empty() {
679            cands.push(baseline_selection(baseline_state, id));
680        }
681        let mut next: Vec<BTreeMap<EventId, Vec<LotPick>>> = Vec::new();
682        for m in &maps {
683            for c in &cands {
684                let mut m2 = m.clone();
685                m2.insert(id.clone(), c.clone());
686                next.push(m2);
687            }
688        }
689        maps = next;
690    }
691    maps.sort();
692    maps.dedup();
693    maps
694}
695
696// ── Task 4 — Mode-1 optimizer `optimize_year` ──────────────────────────────────────────────────────
697
698/// Overall cartesian-product ceiling: exhaustive (PROVEN global minimum) below it, baseline-seeded
699/// coordinate descent (a disclosed LOCAL optimum) above it.
700const MAX_COMBOS: usize = 50_000;
701
702/// §C.1/C.2 holistic single-year optimizer. Assemble per-disposal candidates (grouping + jointly
703/// enumerating contended same-wallet disposals — R0-C3), holistically score the cartesian product through
704/// B (`score_assignment`), pick the deterministic minimum, and build the what-if `OptimizeProposal`.
705///
706/// **Baseline-seeded (R0-C1).** The incumbent starts at the current-method (baseline) assignment scored at
707/// `base.total_federal_tax_attributable`, so `delta ≤ 0` ALWAYS — the optimizer NEVER recommends an
708/// assignment worse than doing nothing, in BOTH the exhaustive and the coordinate-descent path.
709///
710/// **`approximate` honesty (R2-C1/R0-C1/R0-C3).** `approximate == false` ⇔ the vertex set was FULLY
711/// enumerated AND exhaustively scored = a PROVEN global minimum (every pool ≤ `LOT_ENUM_BOUND`, overall
712/// product ≤ `MAX_COMBOS`, every contended pool jointly enumerated). Otherwise `approximate == true` with
713/// the most-severe `ApproxReason` (precedence `ComboCapExceeded` > `ContentionUnenumerated` >
714/// `PoolHeuristic`); `approximate ⇔ approx_reason.is_some()`.
715///
716/// **Refusals.** Pre-2025 → `PreTransitionYear` (a restatement, not an optimization — M7); a
717/// `NotComputable` year → `YearNotComputable` (I6); a year with no method-honoring disposals → `NoDisposals`.
718/// Side-effect-free: computes a proposal; appends NOTHING.
719///
720/// `proposal_made` is the proposed picks' made-date threaded from the CLI seam (core stays clock-free,
721/// NFR4); it drives each row's HONEST compliance + persistability. `attested` is the CLI-supplied attested
722/// disposal set (empty for pure what-if).
723#[allow(clippy::too_many_arguments)]
724pub fn optimize_year(
725    events: &[LedgerEvent],
726    prices: &dyn PriceProvider,
727    config: &ProjectionConfig,
728    year: i32,
729    profile: Option<&TaxProfile>,
730    tables: &dyn TaxTables,
731    attested: &BTreeSet<EventId>,
732    proposal_made: TaxDate,
733) -> Result<OptimizeProposal, OptimizeError> {
734    if year < TRANSITION_DATE.year() {
735        return Err(OptimizeError::PreTransitionYear(year));
736    }
737    // Baseline = current filing position (no injected selections).
738    let baseline_state = fold_with(events, prices, config, &BTreeMap::new());
739    let base = match compute_tax_year(events, &baseline_state, year, profile, tables) {
740        TaxOutcome::Computed(r) => r,
741        TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
742    };
743
744    // The year's method-honoring disposals (Disposal records for `year`), in EventId order (NFR4).
745    let mut targets: Vec<(EventId, WalletId, TaxDate, Sat)> = baseline_state
746        .disposals
747        .iter()
748        .filter(|d| !d.fee_mini_disposition && d.disposed_at.year() == year)
749        .filter_map(|d| {
750            let wallet = events
751                .iter()
752                .find(|e| e.id == d.event)
753                .and_then(|e| e.wallet.clone())?;
754            let sat: Sat = d.legs.iter().map(|l| l.sat).sum();
755            Some((d.event.clone(), wallet, d.disposed_at, sat))
756        })
757        .collect();
758    targets.sort_by(|a, b| a.0.cmp(&b.0));
759    if targets.is_empty() {
760        return Err(OptimizeError::NoDisposals);
761    }
762
763    // R0-C3: group into contention groups; each group's candidate list is a Vec of partial assignments
764    // (JOINT where contended, independent for singletons). A contended group that cannot be jointly
765    // enumerated within GROUP_COMBO_BOUND falls back to independent generation AND flags the proposal.
766    let groups = contention_groups(events, prices, config, &targets);
767    let mut group_lists: Vec<Vec<BTreeMap<EventId, Vec<LotPick>>>> = Vec::new();
768    let mut product: usize = 1;
769    let mut approximate = false;
770    let mut contended_unenum = 0usize;
771    // R2-C1: the largest pool that used the `> LOT_ENUM_BOUND` heuristic (INCOMPLETE) branch.
772    let mut pool_heuristic_lots: Option<usize> = None;
773    for g in &groups {
774        let members: Vec<(EventId, WalletId, TaxDate, Sat)> =
775            g.iter().map(|&i| targets[i].clone()).collect();
776        let maps: Vec<BTreeMap<EventId, Vec<LotPick>>> = if members.len() == 1 {
777            let (id, wallet, date, need) = &members[0]; // singleton: today's independent path
778            let lots = available_lots_before(events, prices, config, id, *date, wallet);
779            let (mut cands, heuristic) = candidate_selections(&lots, *need);
780            if heuristic {
781                pool_heuristic_lots =
782                    Some(pool_heuristic_lots.map_or(lots.len(), |m| m.max(lots.len())));
783            }
784            if cands.is_empty() {
785                cands.push(baseline_selection(&baseline_state, id));
786            }
787            cands
788                .into_iter()
789                .map(|p| BTreeMap::from([(id.clone(), p)]))
790                .collect()
791        } else {
792            match group_candidate_assignments(events, prices, config, &members) {
793                Some((joint, heur_lots)) => {
794                    if let Some(n) = heur_lots {
795                        pool_heuristic_lots = Some(pool_heuristic_lots.map_or(n, |m| m.max(n)));
796                    }
797                    joint
798                }
799                None => {
800                    approximate = true;
801                    contended_unenum += members.len();
802                    independent_group_maps(events, prices, config, &baseline_state, &members)
803                }
804            }
805        };
806        product = product.saturating_mul(maps.len());
807        group_lists.push(maps);
808    }
809    if pool_heuristic_lots.is_some() {
810        approximate = true; // R2-C1: a heuristic pool is never a "proven" optimum
811    }
812
813    // R0-C1: BASELINE-SEED so `delta ≤ 0` ALWAYS (never recommend worse-than-doing-nothing).
814    let baseline_assignment: BTreeMap<EventId, Vec<LotPick>> = targets
815        .iter()
816        .map(|(id, ..)| (id.clone(), baseline_selection(&baseline_state, id)))
817        .collect();
818    // Exhaustive (PROVEN optimum, approximate=false) within MAX_COMBOS; else baseline-seeded coordinate
819    // descent (a disclosed LOCAL optimum, approximate=true). Both incumbents START at the baseline score.
820    // The tracked `best_total` is the score the search actually selected — baseline-seeded so it is
821    // always ≤ baseline_total by construction (the seed is never evicted unless something strictly lower
822    // is found). We use it directly for `optimized_tax`/`delta` instead of re-folding `best` to avoid a
823    // pro-rata remainder-cent that can shift between ST and LT legs when picks are re-injected in lot-id
824    // order rather than the original fold's FIFO order (a ≤$0.01 delta violation on multi-leg disposals).
825    let (best, best_total): (BTreeMap<EventId, Vec<LotPick>>, Usd) = if product <= MAX_COMBOS {
826        exhaustive_min(
827            events,
828            prices,
829            config,
830            year,
831            profile,
832            tables,
833            &group_lists,
834            &baseline_assignment,
835            &base,
836        )
837    } else {
838        approximate = true;
839        coordinate_descent(
840            events,
841            prices,
842            config,
843            year,
844            profile,
845            tables,
846            &group_lists,
847            &baseline_assignment,
848            &base,
849        )
850    };
851    // Reason precedence (R2-C1): blown overall product > un-enumerated contention > per-pool heuristic. All
852    // three set `approximate`; precedence only picks which (most-severe) reason is reported.
853    let approx_reason = if product > MAX_COMBOS {
854        Some(ApproxReason::ComboCapExceeded {
855            combos: product,
856            cap: MAX_COMBOS,
857        })
858    } else if contended_unenum > 0 {
859        Some(ApproxReason::ContentionUnenumerated {
860            contended: contended_unenum,
861            combos: product,
862            cap: MAX_COMBOS,
863        })
864    } else {
865        pool_heuristic_lots.map(|lots| ApproxReason::PoolHeuristic {
866            lots,
867            bound: LOT_ENUM_BOUND,
868        })
869    };
870
871    // Re-fold `best` to extract `marginal_rates` (needed for the proposal). We do NOT use this fold's
872    // `.total_federal_tax_attributable` for `optimized_tax` or `delta` — see comment above the search.
873    let opt_state = fold_with(events, prices, config, &best);
874    let opt = match compute_tax_year(events, &opt_state, year, profile, tables) {
875        TaxOutcome::Computed(r) => r,
876        TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
877    };
878
879    // Per-disposal proposal rows. R0-C2: status/persistability are judged by the PROPOSED pick's OWN
880    // timeliness, NOT by `disposal_compliance(events, opt_state)` (which lacks the injected pick → a
881    // divergent post-hoc cherry-pick would fall through to a compliant StandingOrder — FORBIDDEN, §0).
882    // A's `disposal_compliance(events, &baseline_state)` supplies only the BASELINE status, used to
883    // preserve a genuine StandingOrder/Contemporaneous when the proposal does NOT diverge from current.
884    let base_comp = disposal_compliance(events, &baseline_state);
885    let mut rows: Vec<DisposalCompliance> = Vec::new();
886    let mut row_meta: Vec<RowMeta> = Vec::new();
887    for (id, wallet, date, _need) in &targets {
888        let current = baseline_selection(&baseline_state, id);
889        let proposed = best.get(id).cloned().unwrap_or_else(|| current.clone());
890        let baseline_status = base_comp
891            .iter()
892            .find(|c| &c.disposal == id)
893            .map(|c| c.status.clone())
894            .unwrap_or(ComplianceStatus::NonCompliant);
895        let status = proposed_compliance_status(
896            wallet,
897            *date,
898            proposal_made,
899            &proposed,
900            &current,
901            &baseline_status,
902        );
903        rows.push(DisposalCompliance {
904            disposal: id.clone(),
905            wallet: wallet.clone(),
906            date: *date,
907            status,
908        });
909        row_meta.push((id.clone(), wallet.clone(), *date, current, proposed));
910    }
911    // Task-5 overlay: NonCompliant + attested + within envelope + proposed==current → AttestedRecording.
912    let unchanged: BTreeSet<EventId> = row_meta
913        .iter()
914        .filter(|(_, _, _, current, proposed)| proposed == current)
915        .map(|(id, ..)| id.clone())
916        .collect();
917    let rows = compliance_overlay(&rows, attested, &unchanged);
918
919    let per_disposal: Vec<DisposalProposal> = row_meta
920        .into_iter()
921        .zip(rows)
922        .map(
923            |((id, wallet, date, current, proposed), row)| DisposalProposal {
924                disposal: id,
925                wallet: wallet.clone(),
926                date,
927                current_selection: current,
928                proposed_selection: proposed,
929                status: row.status,
930                // R0-C2/N2: the REAL made-date governs persistability — only genuinely-contemporaneous
931                // picks (made ≤ sale) are persistable; 2027+ broker NEVER.
932                persistable: persistability(&wallet, date, proposal_made),
933            },
934        )
935        .collect();
936
937    Ok(OptimizeProposal {
938        year,
939        baseline_tax: base.total_federal_tax_attributable,
940        // Use the search's tracked incumbent score, NOT the re-fold's total. The re-fold can shift a
941        // pro-rata remainder cent between an ST and an LT leg (picks are in lot-id order rather than
942        // the original FIFO order), causing `opt.total` to exceed `base.total` by ≤$0.01 and breaking
943        // the "ALWAYS ≤ 0" struct-doc invariant on multi-leg disposals where best==baseline.
944        // `best_total` is baseline-seeded and only evicted by a strict improvement, so it is ≤
945        // `base.total_federal_tax_attributable` by construction (delta ≤ 0 holds exactly).
946        optimized_tax: best_total,
947        delta: best_total - base.total_federal_tax_attributable,
948        per_disposal,
949        marginal_rates: opt.marginal_rates, // re-fold still needed for this field
950        approximate,
951        approx_reason,
952    })
953}
954
955/// The lots the CURRENT projection consumes for `disposal` (its baseline disposal legs), as picks, sorted
956/// by lot id (canonical — matches `candidate_selections`'s ordering so lex tie-breaks are consistent).
957fn baseline_selection(state: &LedgerState, disposal: &EventId) -> Vec<LotPick> {
958    let mut picks: Vec<LotPick> = state
959        .disposals
960        .iter()
961        .find(|d| &d.event == disposal)
962        .map(|d| {
963            d.legs
964                .iter()
965                .map(|l| LotPick {
966                    lot: l.lot_id.clone(),
967                    sat: l.sat,
968                })
969                .collect()
970        })
971        .unwrap_or_default();
972    picks.sort_by(|a, b| a.lot.cmp(&b.lot));
973    picks
974}
975
976/// Exhaustive cartesian-product minimisation over the per-GROUP candidate lists (odometer, no recursion).
977/// Each combination merges one chosen partial-map per group into a single assignment, scores it via
978/// `score_assignment`, and keeps the minimum `total_federal_tax_attributable`. Infeasible cross-disposal
979/// combinations self-eliminate (`NotComputable` → skipped). R0-C1: the incumbent is SEEDED with
980/// `baseline_assignment` at `base.total_federal_tax_attributable`, so the result can never be worse than
981/// the baseline. **Tie-break: STRICT-ONLY eviction (`total < best_total` only).** On an exact tie the
982/// baseline incumbent is kept → `proposed == current` at delta == 0 (no needless churn; no pointless
983/// `--attest` prompt for a no-benefit divergent pick). Among strictly-better candidates the first
984/// encountered in the deterministic sorted iteration order wins (candidates come from
985/// `BTreeSet`-sorted `candidate_selections`, so the order is stable and deterministic — NFR4).
986#[allow(clippy::too_many_arguments)]
987fn exhaustive_min(
988    events: &[LedgerEvent],
989    prices: &dyn PriceProvider,
990    config: &ProjectionConfig,
991    year: i32,
992    profile: Option<&TaxProfile>,
993    tables: &dyn TaxTables,
994    group_lists: &[Vec<BTreeMap<EventId, Vec<LotPick>>>],
995    baseline_assignment: &BTreeMap<EventId, Vec<LotPick>>,
996    base: &TaxResult,
997) -> (BTreeMap<EventId, Vec<LotPick>>, Usd) {
998    let mut best_total = base.total_federal_tax_attributable;
999    let mut best_assign = baseline_assignment.clone();
1000    let lens: Vec<usize> = group_lists.iter().map(|g| g.len()).collect();
1001    if lens.contains(&0) {
1002        return (best_assign, best_total); // a group with no candidates → only the baseline is considered
1003    }
1004    let mut idx = vec![0usize; group_lists.len()];
1005    loop {
1006        let mut assign: BTreeMap<EventId, Vec<LotPick>> = BTreeMap::new();
1007        for (gi, &ci) in idx.iter().enumerate() {
1008            for (k, v) in &group_lists[gi][ci] {
1009                assign.insert(k.clone(), v.clone());
1010            }
1011        }
1012        if let TaxOutcome::Computed(r) =
1013            score_assignment(events, prices, config, year, profile, tables, &assign)
1014        {
1015            let total = r.total_federal_tax_attributable;
1016            // C-M1: strict-only eviction — keep the baseline on exact ties (proposed==current at
1017            // delta==0; no needless churn or auto-persist of a no-benefit divergent pick).
1018            if total < best_total {
1019                best_total = total;
1020                best_assign = assign;
1021            }
1022        }
1023        // odometer increment over the per-group index vector
1024        let mut k = 0;
1025        loop {
1026            if k == idx.len() {
1027                return (best_assign, best_total);
1028            }
1029            idx[k] += 1;
1030            if idx[k] < lens[k] {
1031                break;
1032            }
1033            idx[k] = 0;
1034            k += 1;
1035        }
1036    }
1037}
1038
1039/// Deterministic, BASELINE-SEEDED coordinate descent for products beyond `MAX_COMBOS`. R0-C1: START from
1040/// `baseline_assignment` (NOT all-HIFO), so the incumbent is the current filing position and
1041/// `optimized_tax ≤ baseline_tax` holds even if every candidate basin is worse than baseline. Then, per
1042/// group in order (a singleton group = one disposal), hold the others fixed and pick its best candidate by
1043/// full-year score, accepting a move ONLY if it strictly lowers the total; iterate to a fixed point
1044/// (bounded passes). No float, no RNG, no clock (NFR4/NFR5).
1045#[allow(clippy::too_many_arguments)]
1046fn coordinate_descent(
1047    events: &[LedgerEvent],
1048    prices: &dyn PriceProvider,
1049    config: &ProjectionConfig,
1050    year: i32,
1051    profile: Option<&TaxProfile>,
1052    tables: &dyn TaxTables,
1053    group_lists: &[Vec<BTreeMap<EventId, Vec<LotPick>>>],
1054    baseline_assignment: &BTreeMap<EventId, Vec<LotPick>>,
1055    base: &TaxResult,
1056) -> (BTreeMap<EventId, Vec<LotPick>>, Usd) {
1057    let mut current = baseline_assignment.clone();
1058    let mut current_total = base.total_federal_tax_attributable;
1059    let n_groups = group_lists.len();
1060    let pass_cap = n_groups + 1; // bounded passes (deterministic termination)
1061    let mut passes = 0;
1062    let mut changed = true;
1063    while changed && passes < pass_cap {
1064        changed = false;
1065        passes += 1;
1066        for group in group_lists.iter() {
1067            let mut best: Option<(Usd, BTreeMap<EventId, Vec<LotPick>>)> = None;
1068            for cand in group {
1069                let mut assign = current.clone();
1070                for (k, v) in cand {
1071                    assign.insert(k.clone(), v.clone());
1072                }
1073                if let TaxOutcome::Computed(r) =
1074                    score_assignment(events, prices, config, year, profile, tables, &assign)
1075                {
1076                    let total = r.total_federal_tax_attributable;
1077                    match &best {
1078                        None => best = Some((total, assign)),
1079                        Some((bt, ba)) => {
1080                            if total < *bt || (total == *bt && &assign < ba) {
1081                                best = Some((total, assign));
1082                            }
1083                        }
1084                    }
1085                }
1086            }
1087            if let Some((bt, ba)) = best {
1088                if bt < current_total {
1089                    // strict improvement only ⇒ optimized ≤ baseline (delta ≤ 0); deterministic
1090                    current = ba;
1091                    current_total = bt;
1092                    changed = true;
1093                }
1094            }
1095        }
1096    }
1097    (current, current_total)
1098}
1099
1100// ── Task 6 — Mode-2 pre-trade consult `consult_sale` (synthetic disposal + ST→LT timing) ────────────
1101
1102/// §C.3 READ-ONLY pre-trade consultation. For a HYPOTHETICAL sale (sell `req.sell_sat` from
1103/// `req.wallet` at `req.at`, with `req.proceeds` or dataset FMV) pick the tax-minimizing lot selection,
1104/// report the resulting ST/LT split + the year's federal tax, and the ST→LT crossover timing insight.
1105///
1106/// **Side-effect-free (Mode-2 produces NOTHING — §0).** `events`/`prices`/`config` are borrowed
1107/// read-only; every fold is clone-fold-discard (`resolve` → mutate an owned `Resolution` → `fold` →
1108/// read → drop). The function appends NO event, writes NO side-table, makes NO decision — it returns a
1109/// `ConsultReport` only. It is tax decision-support (consequences), NOT buy/sell advice.
1110///
1111/// **Scope.** Optimizes ONLY the synthetic disposal's selection (existing disposals keep their current
1112/// identification — a single-disposal what-if, not a year-wide re-optimization). Deterministic (NFR4),
1113/// exact (NFR5 — every dollar comes straight from B; C never re-rounds).
1114///
1115/// **Profile threading.** The CLI loads the year's `TaxProfile` and passes it in (`year_profile`) so
1116/// core stays clock-free; a missing profile → the underlying `compute_tax_year` returns
1117/// `TaxProfileMissing` → `OptimizeError::YearNotComputable`.
1118///
1119/// **Refusals.** Pre-2025 `at` → `PreTransitionYear` (M7); an empty as-of pool / insufficient holdings →
1120/// `NoLots`; a future date with no dataset price AND no `proceeds` → `Evaluate(ProceedsRequired)`; a
1121/// `NotComputable` year → `YearNotComputable` (I6).
1122pub fn consult_sale(
1123    events: &[LedgerEvent],
1124    prices: &dyn PriceProvider,
1125    config: &ProjectionConfig,
1126    year_profile: Option<&TaxProfile>,
1127    tables: &dyn TaxTables,
1128    req: &ConsultRequest,
1129) -> Result<ConsultReport, OptimizeError> {
1130    let year = req.at.year();
1131    if year < TRANSITION_DATE.year() {
1132        return Err(OptimizeError::PreTransitionYear(year));
1133    }
1134
1135    // R0-M3: available lots = the wallet pool AS OF `at` (fold the canonical timeline truncated to
1136    // `date() <= at`, seed at the boundary). Correct for an interleaved/past `at`, not only the
1137    // forward-looking case. Per-wallet (§1.1012-1(j)); `remaining_sat > 0`; sorted by lot_id (NFR4).
1138    let pre = fold_as_of(events, prices, config, req.at);
1139    let want = pool_key(req.at, &req.wallet);
1140    let mut lots: Vec<Lot> = pre
1141        .lots
1142        .into_iter()
1143        .filter(|l| {
1144            l.remaining_sat > 0 && pool_key(req.at, &l.wallet) == want && l.acquired_at <= req.at
1145        })
1146        .collect();
1147    lots.sort_by(|a, b| a.lot_id.cmp(&b.lot_id));
1148    if lots.iter().map(|l| l.remaining_sat).sum::<Sat>() < req.sell_sat {
1149        return Err(OptimizeError::NoLots);
1150    }
1151
1152    let candidate = CandidateDisposal {
1153        existing_event: None, // synthetic (Mode-2)
1154        wallet: req.wallet.clone(),
1155        date: req.at,
1156        sat: req.sell_sat,
1157        kind: req.kind,
1158        proceeds: req.proceeds, // None on a future date with no dataset price → ProceedsRequired
1159    };
1160    // Resolve proceeds once up front so a missing future price fails fast with ProceedsRequired
1161    // (mirrors A's `evaluate_disposal` proceeds resolution: explicit > dataset FMV > error).
1162    if req.proceeds.is_none() && fmv_of(prices, req.at, req.sell_sat).is_none() {
1163        return Err(OptimizeError::Evaluate(EvaluateError::ProceedsRequired));
1164    }
1165
1166    // C-M2: Enumerate candidate selections for the synthetic disposal and score each via the synthetic
1167    // evaluate+compute path; pick the deterministic minimum federal tax. The heuristic flag (formerly
1168    // discarded as `_heuristic`) is NOW surfaced in `ConsultReport::approximate` so the CLI renderer
1169    // can show a disclosure note for large (>12-lot) pools — symmetric with Mode-1's `PoolHeuristic`
1170    // banner. Every candidate is drawn from the as-of pool with sufficient remaining, so all are
1171    // feasible (the `?` below never trips on a generated candidate).
1172    let (cands, heuristic) = candidate_selections(&lots, req.sell_sat);
1173    let mut best: Option<(Usd, Vec<LotPick>, Usd, Usd)> = None; // (total, picks, st, lt)
1174    for picks in &cands {
1175        let (st, lt, total) = score_synthetic(
1176            events,
1177            prices,
1178            config,
1179            year_profile,
1180            tables,
1181            &candidate,
1182            picks,
1183        )?;
1184        let cand = (total, picks.clone(), st, lt);
1185        best = Some(match best {
1186            None => cand,
1187            Some(b) if (cand.0, &cand.1) < (b.0, &b.1) => cand, // min tax, tie → smallest picks
1188            Some(b) => b,
1189        });
1190    }
1191    let (total, proposed_selection, st_gain, lt_gain) = best.ok_or(OptimizeError::NoLots)?;
1192
1193    // [consult fix] `total` above is the WHOLE-YEAR figure (real disposals + this hyp, vs no crypto).
1194    // On a year that already has real disposals it over-reports the hypothetical's OWN effect. Subtract
1195    // the baseline (the real projection WITHOUT the synthetic sale) — the shared no-crypto term cancels
1196    // exactly — to get the sale's true marginal cost. Same seam as `whatif::synthetic_year`.
1197    let baseline_total = match compute_tax_year(
1198        events,
1199        &fold(resolve(events, prices, config), prices, config),
1200        year,
1201        year_profile,
1202        tables,
1203    ) {
1204        TaxOutcome::Computed(r) => r.total_federal_tax_attributable,
1205        TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
1206    };
1207    let marginal_tax = total - baseline_total;
1208
1209    // ST→LT timing insight (R0-I4/M4): OMITTED (None) — never `Err` — when no leg is short-term, when a
1210    // contributing lot's crossover hits the `next_day` max-date edge, or when the crossover lands outside
1211    // `at`'s bundled year/profile. An unbundled crossover year degrades gracefully (the consult still
1212    // returns the what-if) instead of failing on a missing future table.
1213    let timing = timing_insight(
1214        events,
1215        prices,
1216        config,
1217        year_profile,
1218        tables,
1219        &candidate,
1220        &proposed_selection,
1221        &lots,
1222        total,
1223    );
1224
1225    Ok(ConsultReport {
1226        req: req.clone(),
1227        proposed_selection,
1228        st_gain,
1229        lt_gain,
1230        total_federal_tax_attributable: total,
1231        marginal_tax, // [consult fix] the sale's own effect (headline); `total` is the whole-year figure
1232        timing,
1233        approximate: heuristic, // C-M2: surface the incomplete-pool flag for the renderer
1234    })
1235}
1236
1237/// As-of-`at` pool: clone-fold the canonical timeline truncated to events with `date() <= at` (R0-M3),
1238/// delegating to `fold::state_as_of` (which reuses `fold`'s `sort_canonical` + transition partition and
1239/// fires the boundary seed at the correct point). Sibling of `available_lots_before` (which truncates
1240/// before a specific disposal id rather than at a date). Read-only: `resolve` yields an owned
1241/// `Resolution`, the resulting `LedgerState` is the caller's to read then discard.
1242pub(crate) fn fold_as_of(
1243    events: &[LedgerEvent],
1244    prices: &dyn PriceProvider,
1245    config: &ProjectionConfig,
1246    at: TaxDate,
1247) -> LedgerState {
1248    let res = resolve(events, prices, config);
1249    state_as_of(res, prices, config, at)
1250}
1251
1252/// Clone-fold of the canonical timeline with a synthetic `Op::Dispose` appended (mirroring
1253/// `evaluate.rs`'s synthetic-append) + the candidate selection injected, WITHOUT mutating the ledger.
1254/// Returns the resulting `LedgerState` (read then discarded by the caller). The synthetic event uses
1255/// the reserved sentinel id `EventId::Decision { seq: u64::MAX }` (unreachable for real sequences,
1256/// never persisted — no I/O on this path). Proceeds resolve explicit > dataset FMV > `ProceedsRequired`,
1257/// identically to `evaluate_disposal`, so the parallel fold's legs match A's split.
1258///
1259/// `picks == None` ⇒ NO selection is injected, so the fold consumes the synthetic disposal by the
1260/// STANDING method (`applicable_method`) — used by `whatif::sell`'s default path and `whatif::harvest`.
1261/// `Some(picks)` ⇒ that exact selection is injected (the optimizer's scoring path). `pub(crate)` so the
1262/// crate-internal `whatif` module can reuse the seam without widening the public API.
1263pub(crate) fn synthetic_state(
1264    events: &[LedgerEvent],
1265    prices: &dyn PriceProvider,
1266    config: &ProjectionConfig,
1267    candidate: &CandidateDisposal,
1268    picks: Option<&[LotPick]>,
1269) -> Result<LedgerState, EvaluateError> {
1270    let mut res = resolve(events, prices, config);
1271    let proceeds = match candidate.proceeds {
1272        Some(p) => p,
1273        None => {
1274            fmv_of(prices, candidate.date, candidate.sat).ok_or(EvaluateError::ProceedsRequired)?
1275        }
1276    };
1277    let id = EventId::Decision { seq: u64::MAX };
1278    // midnight().assume_utc() → UTC 00:00:00 on `candidate.date`; tax_date(utc, UTC) == candidate.date.
1279    let utc = candidate.date.midnight().assume_utc();
1280    res.timeline.push(Eff {
1281        id: id.clone(),
1282        utc,
1283        tz: time::UtcOffset::UTC,
1284        src_priority: 0,
1285        src_ref: SourceRef::new("__synthetic__"),
1286        wallet: Some(candidate.wallet.clone()),
1287        op: Op::Dispose {
1288            sat: candidate.sat,
1289            proceeds,
1290            fee_usd: Usd::ZERO,
1291            fee_sat: None,
1292            kind: candidate.kind,
1293        },
1294        pseudo: false, // synthetic optimizer candidate — unrelated to pseudo-reconcile mode
1295    });
1296    // Inject the selection only when one is given; otherwise the fold consumes by the standing method.
1297    if let Some(picks) = picks {
1298        res.selections.insert(id, picks.to_vec());
1299    }
1300    Ok(fold(res, prices, config))
1301}
1302
1303/// Score one synthetic-disposal selection: A's `evaluate_disposal` gives the per-leg ST/LT split, and a
1304/// parallel synthetic fold + `compute_tax_year` gives the YEAR's federal tax (the holistic objective —
1305/// cross-netting with any other in-year crypto is captured, matching Mode-1). Both are clone-fold-discard
1306/// (no mutation). Returns `(st_gain, lt_gain, total_federal_tax_attributable)`. An infeasible selection
1307/// → the fold raises `LotSelectionInvalid` → `compute_tax_year` `NotComputable` → `YearNotComputable`
1308/// (generated candidates are always feasible, so this only guards a hand-built call).
1309#[allow(clippy::too_many_arguments)]
1310fn score_synthetic(
1311    events: &[LedgerEvent],
1312    prices: &dyn PriceProvider,
1313    config: &ProjectionConfig,
1314    year_profile: Option<&TaxProfile>,
1315    tables: &dyn TaxTables,
1316    candidate: &CandidateDisposal,
1317    picks: &[LotPick],
1318) -> Result<(Usd, Usd, Usd), OptimizeError> {
1319    // 1) ST/LT split for THIS disposal via A's side-effect-free entrypoint.
1320    let out = evaluate_disposal(events, prices, config, candidate, Some(picks))
1321        .map_err(OptimizeError::Evaluate)?;
1322    // 2) Full-year federal tax via a parallel synthetic fold (same append + injected selection).
1323    let state = synthetic_state(events, prices, config, candidate, Some(picks))
1324        .map_err(OptimizeError::Evaluate)?;
1325    let year = candidate.date.year();
1326    match compute_tax_year(events, &state, year, year_profile, tables) {
1327        TaxOutcome::Computed(r) => Ok((out.st_gain, out.lt_gain, r.total_federal_tax_attributable)),
1328        TaxOutcome::NotComputable(b) => Err(OptimizeError::YearNotComputable(b)),
1329    }
1330}
1331
1332/// ST→LT crossover timing insight — returns `Option<TimingInsight>` (OMIT, never error — R0-I4/M4).
1333///
1334/// For each pick in the chosen selection, find its source lot; the lot is short-term as of `at` iff
1335/// `!is_long_term(lot.gain_hp_start(), at)`. If NONE are short-term → `None`. Otherwise
1336/// `st_sat_in_selection` = Σ their sats and `latest_crossover` = max over them of the first STRICTLY
1337/// long-term date = `one_year_after(gain_hp_start).next_day()`. **R0-M4:** `Date::next_day()` is `Option`
1338/// (Dec-31 / max-date edge) — `None` for any contributing lot ⇒ OMIT (no unwrap).
1339///
1340/// **R0-I4 (same year/profile, term-flip, degrade).** `tax_if_sold_long_term` is computed WITHIN THE
1341/// SAME tax year and profile as `at`: re-score the SAME selection with the SAME proceeds, realized as a
1342/// synthetic disposal dated `latest_crossover` — so the short-term legs flip to long-term while the price
1343/// is unchanged (lots already LT as of `at` stay LT). Done ONLY when `latest_crossover.year() ==
1344/// at.year()` AND `at`'s table + profile are present; OTHERWISE `None` — NEVER re-score in a future
1345/// crossover year. The real guard is the `latest_crossover.year() != at.year()` check below: a forward
1346/// re-score would need that later year's bundled table AND profile, and any crossover year past the
1347/// bundled/profiled horizon would resolve to `NotComputable` and fail the whole consult — so the
1348/// same-year guard keeps the insight self-consistent regardless of which years are bundled.
1349/// `saving_if_waited = (total_now − tax_if_sold_long_term).max(0)`.
1350#[allow(clippy::too_many_arguments)]
1351fn timing_insight(
1352    events: &[LedgerEvent],
1353    prices: &dyn PriceProvider,
1354    config: &ProjectionConfig,
1355    year_profile: Option<&TaxProfile>,
1356    tables: &dyn TaxTables,
1357    candidate: &CandidateDisposal,
1358    proposed_selection: &[LotPick],
1359    lots: &[Lot],
1360    total_now: Usd,
1361) -> Option<TimingInsight> {
1362    let at = candidate.date;
1363    let mut st_sat: Sat = 0;
1364    let mut crossover: Option<TaxDate> = None;
1365    for p in proposed_selection {
1366        let lot = lots.iter().find(|l| l.lot_id == p.lot)?;
1367        if !is_long_term(lot.gain_hp_start(), at) {
1368            st_sat += p.sat;
1369            // First strictly-long-term date = anniversary + 1 day; R0-M4: None (max-date edge) ⇒ omit.
1370            let lt_date = one_year_after(lot.gain_hp_start()).next_day()?;
1371            crossover = Some(crossover.map_or(lt_date, |c: TaxDate| c.max(lt_date)));
1372        }
1373    }
1374    let latest_crossover = crossover?; // no short-term leg ⇒ no insight
1375
1376    // R0-I4: stay within `at`'s bundled year/profile; degrade (omit) otherwise — never re-score forward.
1377    if latest_crossover.year() != at.year()
1378        || tables.table_for(at.year()).is_none()
1379        || year_profile.is_none()
1380    {
1381        return None;
1382    }
1383
1384    // tax_if_sold_long_term: the SAME selection + SAME proceeds, dated `latest_crossover` (ST legs now
1385    // LT; price unchanged). Resolve proceeds the same way as the headline score (explicit > FMV).
1386    let proceeds = candidate
1387        .proceeds
1388        .or_else(|| fmv_of(prices, at, candidate.sat))?;
1389    let lt_candidate = CandidateDisposal {
1390        existing_event: None,
1391        wallet: candidate.wallet.clone(),
1392        date: latest_crossover,
1393        sat: candidate.sat,
1394        kind: candidate.kind,
1395        proceeds: Some(proceeds),
1396    };
1397    let (_st, _lt, tax_if_sold_long_term) = score_synthetic(
1398        events,
1399        prices,
1400        config,
1401        year_profile,
1402        tables,
1403        &lt_candidate,
1404        proposed_selection,
1405    )
1406    .ok()?; // any unexpected NotComputable ⇒ omit rather than fail the consult
1407
1408    let saving_if_waited = (total_now - tax_if_sold_long_term).max(Usd::ZERO);
1409    Some(TimingInsight {
1410        st_sat_in_selection: st_sat,
1411        latest_crossover,
1412        tax_if_sold_long_term,
1413        saving_if_waited,
1414    })
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420    use crate::event::LotPick;
1421
1422    #[test]
1423    fn error_variants_are_constructible_and_eq() {
1424        let e = OptimizeError::PreTransitionYear(2024);
1425        assert_eq!(e, OptimizeError::PreTransitionYear(2024));
1426        assert_ne!(e, OptimizeError::NoDisposals);
1427        assert_eq!(
1428            Persistability::ForbiddenBroker2027,
1429            Persistability::ForbiddenBroker2027
1430        );
1431    }
1432
1433    #[test]
1434    fn lot_pick_is_totally_ordered() {
1435        // R0-I2: the dedup/tie-break machinery requires `Vec<LotPick>: Ord`. A BTreeSet of pick-vecs
1436        // must compile and sort deterministically.
1437        use std::collections::BTreeSet;
1438        let mut s: BTreeSet<Vec<LotPick>> = BTreeSet::new();
1439        s.insert(vec![/* pick(b) */]);
1440        s.insert(vec![/* pick(a) */]);
1441        let _sorted: Vec<Vec<LotPick>> = s.into_iter().collect(); // compiles ⇒ LotPick: Ord
1442    }
1443
1444    #[test]
1445    fn approx_reason_variants_are_eq() {
1446        assert_eq!(
1447            ApproxReason::ComboCapExceeded {
1448                combos: 100,
1449                cap: 50_000
1450            },
1451            ApproxReason::ComboCapExceeded {
1452                combos: 100,
1453                cap: 50_000
1454            }
1455        );
1456        assert_eq!(
1457            ApproxReason::ContentionUnenumerated {
1458                contended: 2,
1459                combos: 60_000,
1460                cap: 50_000
1461            },
1462            ApproxReason::ContentionUnenumerated {
1463                contended: 2,
1464                combos: 60_000,
1465                cap: 50_000
1466            }
1467        );
1468        assert_eq!(
1469            ApproxReason::PoolHeuristic {
1470                lots: 15,
1471                bound: 12
1472            },
1473            ApproxReason::PoolHeuristic {
1474                lots: 15,
1475                bound: 12
1476            }
1477        );
1478    }
1479}
1480
1481/// Task 3 — candidate generation KATs. These are in-crate UNIT tests (not `tests/`) because
1482/// `available_lots_before` and `candidate_selections` are private generators (the §2 public surface is
1483/// the documented `optimize_year`/`consult_sale`/`score_assignment`); a `tests/` integration crate
1484/// cannot see them, while a child module can. All fixtures are synthetic (privacy — no real reads).
1485#[cfg(test)]
1486mod candidate_tests {
1487    use super::*;
1488    use crate::event::{
1489        Acquire, AllocLot, AllocMethod, BasisSource, Dispose, EventPayload, SafeHarborAllocation,
1490    };
1491    use crate::identity::{LotId, Source, SourceRef};
1492    use crate::price::StaticPrices;
1493    use crate::LotMethod;
1494    use rust_decimal_macros::dec;
1495    use std::collections::BTreeSet;
1496    use time::macros::{date, datetime, offset};
1497
1498    const LOT: Sat = 100_000_000; // one whole BTC per lot
1499
1500    // ── builders ─────────────────────────────────────────────────────────────────────────────────
1501    fn cold() -> WalletId {
1502        WalletId::SelfCustody {
1503            label: "cold".into(),
1504        }
1505    }
1506    fn hot() -> WalletId {
1507        WalletId::SelfCustody {
1508            label: "hot".into(),
1509        }
1510    }
1511    fn eid(rf: &str) -> EventId {
1512        EventId::import(Source::Swan, SourceRef::new(rf))
1513    }
1514    fn lid(rf: &str) -> LotId {
1515        LotId {
1516            origin_event_id: eid(rf),
1517            split_sequence: 0,
1518        }
1519    }
1520    fn pick(rf: &str, sat: Sat) -> LotPick {
1521        LotPick { lot: lid(rf), sat }
1522    }
1523    /// Test-local mirror of `candidate_selections`'s canonicalization (picks sorted by lot id), so
1524    /// expected sets can be compared regardless of the EventId hash order.
1525    fn canon(mut v: Vec<LotPick>) -> Vec<LotPick> {
1526        v.sort_by(|a, b| a.lot.cmp(&b.lot));
1527        v
1528    }
1529    /// A whole `Lot` built directly (for the pure `candidate_selections`).
1530    fn mklot(rf: &str, acquired: TaxDate, sat: Sat, basis: Usd, wallet: WalletId) -> Lot {
1531        Lot {
1532            lot_id: lid(rf),
1533            wallet,
1534            acquired_at: acquired,
1535            original_sat: sat,
1536            remaining_sat: sat,
1537            usd_basis: basis,
1538            basis_source: BasisSource::ExchangeProvided,
1539            dual_loss_basis: None,
1540            donor_acquired_at: None,
1541            basis_pending: false,
1542            pseudo: false,
1543        }
1544    }
1545    fn ev(rf: &str, ts: time::OffsetDateTime, wallet: WalletId, p: EventPayload) -> LedgerEvent {
1546        LedgerEvent {
1547            id: eid(rf),
1548            utc_timestamp: ts,
1549            original_tz: offset!(+00:00),
1550            wallet: Some(wallet),
1551            payload: p,
1552        }
1553    }
1554    fn buy(
1555        rf: &str,
1556        ts: time::OffsetDateTime,
1557        wallet: WalletId,
1558        sat: Sat,
1559        cost: Usd,
1560    ) -> LedgerEvent {
1561        ev(
1562            rf,
1563            ts,
1564            wallet,
1565            EventPayload::Acquire(Acquire {
1566                sat,
1567                usd_cost: cost,
1568                fee_usd: dec!(0),
1569                basis_source: BasisSource::ExchangeProvided,
1570            }),
1571        )
1572    }
1573    fn sell(
1574        rf: &str,
1575        ts: time::OffsetDateTime,
1576        wallet: WalletId,
1577        sat: Sat,
1578        proceeds: Usd,
1579    ) -> LedgerEvent {
1580        ev(
1581            rf,
1582            ts,
1583            wallet,
1584            EventPayload::Dispose(Dispose {
1585                sat,
1586                usd_proceeds: proceeds,
1587                fee_usd: dec!(0),
1588                kind: DisposeKind::Sell,
1589            }),
1590        )
1591    }
1592    fn cfg() -> ProjectionConfig {
1593        ProjectionConfig::default()
1594    }
1595    /// An effective (attested ⇒ §5.02(4) bar bypassed) `ActualPosition` safe-harbor allocation decision
1596    /// event (Path B). `id = EventId::decision(seq)` ⇒ the seed lots' `origin_event_id` is THIS decision,
1597    /// distinct from any imported buy's lot id. Recorded method FIFO; `as_of_date` the 2025-01-01 snapshot.
1598    fn alloc_event(seq: u64, made: time::OffsetDateTime, lots: Vec<AllocLot>) -> LedgerEvent {
1599        LedgerEvent {
1600            id: EventId::decision(seq),
1601            utc_timestamp: made,
1602            original_tz: offset!(+00:00),
1603            wallet: None,
1604            payload: EventPayload::SafeHarborAllocation(SafeHarborAllocation {
1605                lots,
1606                as_of_date: date!(2025 - 01 - 01),
1607                method: AllocMethod::ActualPosition,
1608                timely_allocation_attested: true, // bypass the §5.02(4) bar so Path B governs
1609                pre2025_method: LotMethod::Fifo,
1610            }),
1611        }
1612    }
1613    fn alloc_lot(w: WalletId, sat: Sat, basis: Usd, acq: TaxDate) -> AllocLot {
1614        AllocLot {
1615            wallet: w,
1616            sat,
1617            usd_basis: basis,
1618            acquired_at: acq,
1619            dual_loss_basis: None,
1620            donor_acquired_at: None,
1621        }
1622    }
1623
1624    // ── candidate_selections (pure vertex enumeration) ─────────────────────────────────────────────
1625
1626    /// Complete vertex set on a small pool: three whole 100k lots, `need = 200k` → exactly the whole-lot
1627    /// pairs `{A,B},{A,C},{B,C}` (the brute-force vertex set), each conserving principal; `heuristic`
1628    /// false (≤ LOT_ENUM_BOUND).
1629    #[test]
1630    fn complete_vertex_set_three_whole_lots() {
1631        let lots = vec![
1632            mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
1633            mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
1634            mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
1635        ];
1636        let (cands, heuristic) = candidate_selections(&lots, 2 * LOT);
1637        assert!(!heuristic, "≤ LOT_ENUM_BOUND ⇒ complete enumeration");
1638        let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
1639        let want: BTreeSet<Vec<LotPick>> = [
1640            canon(vec![pick("A", LOT), pick("B", LOT)]),
1641            canon(vec![pick("A", LOT), pick("C", LOT)]),
1642            canon(vec![pick("B", LOT), pick("C", LOT)]),
1643        ]
1644        .into_iter()
1645        .collect();
1646        assert_eq!(got, want, "enumerated vertices == brute-force vertex set");
1647        for c in &cands {
1648            assert_eq!(
1649                c.iter().map(|p| p.sat).sum::<Sat>(),
1650                2 * LOT,
1651                "principal conservation"
1652            );
1653        }
1654    }
1655
1656    /// One-partial top-up: three 100k lots, `need = 150k` (no whole-lot subset sums to it) → every
1657    /// strict subset summing < need extended by ONE partial; the full set is the six (whole + partial)
1658    /// vertices including `{A(100k),B(50k)}` and `{B(100k),A(50k)}`. All conserve; `heuristic` false.
1659    #[test]
1660    fn one_partial_top_up_vertices() {
1661        let half = LOT / 2;
1662        let lots = vec![
1663            mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
1664            mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
1665            mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
1666        ];
1667        let (cands, heuristic) = candidate_selections(&lots, LOT + half);
1668        assert!(!heuristic);
1669        let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
1670        let want: BTreeSet<Vec<LotPick>> = [
1671            canon(vec![pick("A", LOT), pick("B", half)]),
1672            canon(vec![pick("A", LOT), pick("C", half)]),
1673            canon(vec![pick("B", LOT), pick("A", half)]),
1674            canon(vec![pick("B", LOT), pick("C", half)]),
1675            canon(vec![pick("C", LOT), pick("A", half)]),
1676            canon(vec![pick("C", LOT), pick("B", half)]),
1677        ]
1678        .into_iter()
1679        .collect();
1680        assert_eq!(got, want);
1681        // explicit membership of the two distinct "lead lot + 50k partial" forms (R0/plan example)
1682        assert!(got.contains(&canon(vec![pick("A", LOT), pick("B", half)])));
1683        assert!(got.contains(&canon(vec![pick("B", LOT), pick("A", half)])));
1684        for c in &cands {
1685            assert_eq!(c.iter().map(|p| p.sat).sum::<Sat>(), LOT + half);
1686        }
1687    }
1688
1689    /// NFR4 determinism: byte-identical `Vec` across calls.
1690    #[test]
1691    fn candidate_selections_is_deterministic() {
1692        let lots = vec![
1693            mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
1694            mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
1695            mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
1696        ];
1697        let (c1, h1) = candidate_selections(&lots, 2 * LOT);
1698        let (c2, h2) = candidate_selections(&lots, 2 * LOT);
1699        assert_eq!(c1, c2);
1700        assert_eq!(h1, h2);
1701    }
1702
1703    /// R2-C1, `<= bound`: a pool of exactly LOT_ENUM_BOUND lots → `heuristic == false` (complete).
1704    #[test]
1705    fn heuristic_flag_false_at_bound() {
1706        let lots: Vec<Lot> = (0..LOT_ENUM_BOUND)
1707            .map(|i| {
1708                mklot(
1709                    &format!("L{i}"),
1710                    date!(2025 - 02 - 01),
1711                    LOT,
1712                    dec!(10000),
1713                    cold(),
1714                )
1715            })
1716            .collect();
1717        let (_cands, heuristic) = candidate_selections(&lots, 2 * LOT);
1718        assert!(!heuristic, "exactly LOT_ENUM_BOUND lots ⇒ still complete");
1719    }
1720
1721    /// R2-C1, `> bound`: a pool of 13 lots (> LOT_ENUM_BOUND) → `heuristic == true` and the returned
1722    /// candidates are a STRICT SUBSET of the full vertex set (here all whole-lot pairs, C(13,2) = 78),
1723    /// each still principal-conserving. This is the incomplete-branch signal `optimize_year` propagates
1724    /// into `approximate`/`PoolHeuristic`.
1725    #[test]
1726    fn heuristic_flag_true_above_bound_returns_strict_subset() {
1727        let n = LOT_ENUM_BOUND + 1; // 13
1728        let lots: Vec<Lot> = (0..n)
1729            .map(|i| {
1730                mklot(
1731                    &format!("L{i:02}"),
1732                    date!(2025 - 02 - 01),
1733                    LOT,
1734                    dec!(10000),
1735                    cold(),
1736                )
1737            })
1738            .collect();
1739        let (cands, heuristic) = candidate_selections(&lots, 2 * LOT);
1740        assert!(heuristic, "> LOT_ENUM_BOUND ⇒ heuristic branch");
1741        // full vertex set for equal 100k lots / need=200k = all unordered whole-lot pairs.
1742        let mut full: BTreeSet<Vec<LotPick>> = BTreeSet::new();
1743        for i in 0..n {
1744            for j in (i + 1)..n {
1745                full.insert(canon(vec![
1746                    LotPick {
1747                        lot: lots[i].lot_id.clone(),
1748                        sat: LOT,
1749                    },
1750                    LotPick {
1751                        lot: lots[j].lot_id.clone(),
1752                        sat: LOT,
1753                    },
1754                ]));
1755            }
1756        }
1757        assert_eq!(full.len(), 78);
1758        let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
1759        assert!(got.is_subset(&full), "heuristic candidates are vertices");
1760        assert!(
1761            got.len() < full.len(),
1762            "STRICT subset (incomplete): {} of {}",
1763            got.len(),
1764            full.len()
1765        );
1766        for c in &cands {
1767            assert_eq!(c.iter().map(|p| p.sat).sum::<Sat>(), 2 * LOT);
1768        }
1769    }
1770
1771    // ── available_lots_before (pre-pass: fold::pools_before — canonical + transition seed at boundary) ─
1772
1773    /// R0-I1 — load-order ≠ canonical-order. A lot acquired EARLIER in time but appended LATER in
1774    /// `events`, and a lot acquired LATER in time but appended EARLIER. `available_lots_before(D)` must
1775    /// return exactly the lots acquired-before-`D`-in-TIME: the early-time/late-load lot PRESENT, the
1776    /// late-time/early-load lot ABSENT. Without the `sort_canonical` + partition replication (now inside
1777    /// `fold::pools_before`) this fails (it would cut on load order and keep the wrong lot).
1778    #[test]
1779    fn available_lots_before_respects_canonical_not_load_order() {
1780        // load order: LATE (09-01), D (06-01), EARLY (02-01) — deliberately NOT time order.
1781        let events = vec![
1782            buy(
1783                "LATE",
1784                datetime!(2025-09-01 00:00:00 UTC),
1785                cold(),
1786                LOT,
1787                dec!(50000),
1788            ),
1789            sell(
1790                "D",
1791                datetime!(2025-06-01 00:00:00 UTC),
1792                cold(),
1793                LOT,
1794                dec!(60000),
1795            ),
1796            buy(
1797                "EARLY",
1798                datetime!(2025-02-01 00:00:00 UTC),
1799                cold(),
1800                LOT,
1801                dec!(10000),
1802            ),
1803        ];
1804        let prices = StaticPrices::default();
1805        let lots = available_lots_before(
1806            &events,
1807            &prices,
1808            &cfg(),
1809            &eid("D"),
1810            date!(2025 - 06 - 01),
1811            &cold(),
1812        );
1813        let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
1814        assert!(
1815            ids.contains(&lid("EARLY")),
1816            "acquired-before-D-in-time ⇒ available"
1817        );
1818        assert!(
1819            !ids.contains(&lid("LATE")),
1820            "acquired-after-D-in-time ⇒ NOT available"
1821        );
1822    }
1823
1824    /// Per-wallet (§1.1012-1(j)): a lot in another wallet is EXCLUDED from a post-2025 disposal's pool.
1825    #[test]
1826    fn available_lots_before_excludes_cross_wallet_lot() {
1827        let events = vec![
1828            buy(
1829                "CL",
1830                datetime!(2025-02-01 00:00:00 UTC),
1831                cold(),
1832                LOT,
1833                dec!(10000),
1834            ),
1835            buy(
1836                "HL",
1837                datetime!(2025-02-01 00:00:00 UTC),
1838                hot(),
1839                LOT,
1840                dec!(10000),
1841            ),
1842            sell(
1843                "D",
1844                datetime!(2025-06-01 00:00:00 UTC),
1845                cold(),
1846                LOT,
1847                dec!(60000),
1848            ),
1849        ];
1850        let prices = StaticPrices::default();
1851        let lots = available_lots_before(
1852            &events,
1853            &prices,
1854            &cfg(),
1855            &eid("D"),
1856            date!(2025 - 06 - 01),
1857            &cold(),
1858        );
1859        let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
1860        assert!(ids.contains(&lid("CL")), "same-wallet lot available");
1861        assert!(
1862            !ids.contains(&lid("HL")),
1863            "cross-wallet lot excluded (per-wallet pool)"
1864        );
1865    }
1866
1867    /// Task-3 review IMPORTANT — Path B, the FIRST 2025 timeline event IS the disposal. An effective
1868    /// `SafeHarborAllocation` (Path B) DISCARDS the pre-2025 FIFO residue and installs seed lots whose
1869    /// `lot_id`s (origin = the allocation decision) and per-lot basis DIFFER from the residue. Because
1870    /// decision events are not timeline events (resolve.rs:491), this sell is the chronologically-first
1871    /// ≥2025 timeline event, so the boundary seed must STILL fire before it. `available_lots_before` MUST
1872    /// return the SEEDED lots (origin = allocation, `basis_source == SafeHarborAllocated`), NOT the
1873    /// discarded residue (origin = the pre-2025 buy). FAILS without the `pools_before` seed-at-boundary
1874    /// fix (the old truncate-then-refold never crosses TRANSITION_DATE → surfaces the un-seeded residue).
1875    #[test]
1876    fn available_lots_before_path_b_first_2025_disposal_returns_seeded_lots() {
1877        let alloc_id = EventId::decision(1);
1878        let events = vec![
1879            // pre-2025 buy → Universal residue: 100M sat @ basis 60 (origin = "OLD", ExchangeProvided).
1880            buy(
1881                "OLD",
1882                datetime!(2024-06-01 00:00:00 UTC),
1883                cold(),
1884                LOT,
1885                dec!(60),
1886            ),
1887            // FIRST 2025 timeline event is THIS disposal (the allocation below is a decision, not a tl event).
1888            sell(
1889                "D",
1890                datetime!(2025-02-01 00:00:00 UTC),
1891                cold(),
1892                LOT,
1893                dec!(80),
1894            ),
1895            // Effective Path-B allocation: seed = 40M@20 + 60M@40 = 100M @ 60 — conserves vs the residue,
1896            // but DIFFERENT lot_ids (origin = decision id) AND per-lot basis than the single residue lot.
1897            alloc_event(
1898                1,
1899                datetime!(2025-03-01 00:00:00 UTC),
1900                vec![
1901                    alloc_lot(cold(), 40_000_000, dec!(20), date!(2024 - 05 - 01)),
1902                    alloc_lot(cold(), 60_000_000, dec!(40), date!(2024 - 06 - 01)),
1903                ],
1904            ),
1905        ];
1906        let prices = StaticPrices::default();
1907        let lots = available_lots_before(
1908            &events,
1909            &prices,
1910            &cfg(),
1911            &eid("D"),
1912            date!(2025 - 02 - 01),
1913            &cold(),
1914        );
1915        assert!(!lots.is_empty(), "the post-seed Path-B pool is non-empty");
1916        assert!(
1917            lots.iter().all(|l| l.lot_id.origin_event_id == alloc_id),
1918            "Path-B first-2025-disposal MUST return the SEEDED lots (origin = allocation); got origins {:?}",
1919            lots.iter()
1920                .map(|l| l.lot_id.origin_event_id.clone())
1921                .collect::<Vec<_>>()
1922        );
1923        assert!(
1924            lots.iter()
1925                .all(|l| l.basis_source == BasisSource::SafeHarborAllocated),
1926            "seeded lots carry the SafeHarborAllocated basis_source (not the residue's ExchangeProvided)"
1927        );
1928        let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
1929        assert!(
1930            !ids.contains(&lid("OLD")),
1931            "the DISCARDED FIFO residue lot must NOT appear in a Path-B pool"
1932        );
1933        assert_eq!(lots.len(), 2, "both seed lots present");
1934        assert_eq!(
1935            lots.iter().map(|l| l.remaining_sat).sum::<Sat>(),
1936            LOT,
1937            "seed conserves principal"
1938        );
1939        assert_eq!(
1940            lots.iter().map(|l| l.usd_basis).sum::<Usd>(),
1941            dec!(60),
1942            "seed conserves basis"
1943        );
1944    }
1945
1946    /// Path-A counterpart of the above — FIRST 2025 event is the disposal, NO allocation (Path A default).
1947    /// The pre-2025 Universal residue must be RELOCATED into its wallet pool (lot_id + basis preserved,
1948    /// `basis_source == ReconstructedPerWallet`) by the boundary seed before the disposal. Confirms the
1949    /// seed fires at the boundary under Path A too — and that `available_lots_before` matches the real fold.
1950    #[test]
1951    fn available_lots_before_path_a_first_2025_disposal_relocates_residue() {
1952        let events = vec![
1953            buy(
1954                "OLD",
1955                datetime!(2024-06-01 00:00:00 UTC),
1956                cold(),
1957                LOT,
1958                dec!(60),
1959            ),
1960            sell(
1961                "D",
1962                datetime!(2025-02-01 00:00:00 UTC),
1963                cold(),
1964                LOT,
1965                dec!(80),
1966            ),
1967        ];
1968        let prices = StaticPrices::default();
1969        let lots = available_lots_before(
1970            &events,
1971            &prices,
1972            &cfg(),
1973            &eid("D"),
1974            date!(2025 - 02 - 01),
1975            &cold(),
1976        );
1977        assert_eq!(lots.len(), 1, "the single relocated residue lot");
1978        let l = &lots[0];
1979        assert_eq!(l.lot_id, lid("OLD"), "Path A preserves the residue lot_id");
1980        assert_eq!(l.usd_basis, dec!(60), "Path A preserves basis");
1981        assert_eq!(l.remaining_sat, LOT);
1982        assert_eq!(
1983            l.basis_source,
1984            BasisSource::ReconstructedPerWallet,
1985            "Path A drains the residue into the per-wallet pool at the boundary seed"
1986        );
1987    }
1988
1989    /// NFR4 determinism for the pre-pass.
1990    #[test]
1991    fn available_lots_before_is_deterministic() {
1992        let events = vec![
1993            buy(
1994                "A",
1995                datetime!(2025-02-01 00:00:00 UTC),
1996                cold(),
1997                LOT,
1998                dec!(10000),
1999            ),
2000            buy(
2001                "B",
2002                datetime!(2025-03-01 00:00:00 UTC),
2003                cold(),
2004                LOT,
2005                dec!(20000),
2006            ),
2007            sell(
2008                "D",
2009                datetime!(2025-06-01 00:00:00 UTC),
2010                cold(),
2011                LOT,
2012                dec!(60000),
2013            ),
2014        ];
2015        let prices = StaticPrices::default();
2016        let l1 = available_lots_before(
2017            &events,
2018            &prices,
2019            &cfg(),
2020            &eid("D"),
2021            date!(2025 - 06 - 01),
2022            &cold(),
2023        );
2024        let l2 = available_lots_before(
2025            &events,
2026            &prices,
2027            &cfg(),
2028            &eid("D"),
2029            date!(2025 - 06 - 01),
2030            &cold(),
2031        );
2032        assert_eq!(l1, l2);
2033    }
2034
2035    // ── contention grouping + joint enumeration (R0-C3) ────────────────────────────────────────────
2036
2037    /// Two same-wallet sells drawing from overlapping lots → ONE contention group, and
2038    /// `group_candidate_assignments` yields the cross-period "deviation" sequence (D1 takes the lot the
2039    /// baseline gives D2, freeing the other for D2) that the INDEPENDENT per-disposal product cannot reach.
2040    #[test]
2041    fn contention_groups_one_group_and_joint_reaches_deviation() {
2042        // R acquired earlier (LT-able), P acquired later; two 2026 sells of one lot each, FIFO baseline.
2043        let events = vec![
2044            buy(
2045                "R",
2046                datetime!(2025-05-01 00:00:00 UTC),
2047                cold(),
2048                LOT,
2049                dec!(5000),
2050            ),
2051            buy(
2052                "P",
2053                datetime!(2025-06-15 00:00:00 UTC),
2054                cold(),
2055                LOT,
2056                dec!(5000),
2057            ),
2058            sell(
2059                "D1",
2060                datetime!(2026-06-01 00:00:00 UTC),
2061                cold(),
2062                LOT,
2063                dec!(10000),
2064            ),
2065            sell(
2066                "D2",
2067                datetime!(2026-06-20 00:00:00 UTC),
2068                cold(),
2069                LOT,
2070                dec!(10000),
2071            ),
2072        ];
2073        let prices = StaticPrices::default();
2074        let mut targets = vec![
2075            (eid("D1"), cold(), date!(2026 - 06 - 01), LOT),
2076            (eid("D2"), cold(), date!(2026 - 06 - 20), LOT),
2077        ];
2078        targets.sort_by(|a, b| a.0.cmp(&b.0));
2079
2080        let groups = contention_groups(&events, &prices, &cfg(), &targets);
2081        assert_eq!(
2082            groups.len(),
2083            1,
2084            "overlapping same-wallet disposals → one group"
2085        );
2086        assert_eq!(groups[0].len(), 2);
2087
2088        let members: Vec<_> = groups[0].iter().map(|&i| targets[i].clone()).collect();
2089        let (maps, heur) =
2090            group_candidate_assignments(&events, &prices, &cfg(), &members).expect("within bound");
2091        assert_eq!(heur, None, "small pools → no heuristic branch");
2092        // Joint set = {(D1=R,D2=P), (D1=P,D2=R)} — the second is the deviation unreachable independently.
2093        let rp: BTreeMap<EventId, Vec<LotPick>> = [
2094            (eid("D1"), vec![pick("R", LOT)]),
2095            (eid("D2"), vec![pick("P", LOT)]),
2096        ]
2097        .into_iter()
2098        .collect();
2099        let pr: BTreeMap<EventId, Vec<LotPick>> = [
2100            (eid("D1"), vec![pick("P", LOT)]),
2101            (eid("D2"), vec![pick("R", LOT)]),
2102        ]
2103        .into_iter()
2104        .collect();
2105        assert!(maps.contains(&rp), "baseline-consistent sequence present");
2106        assert!(
2107            maps.contains(&pr),
2108            "cross-period deviation sequence present (joint-only)"
2109        );
2110        assert_eq!(maps.len(), 2);
2111    }
2112
2113    /// Two disposals on DIFFERENT wallets → two singleton groups (disjoint pools, never contended).
2114    #[test]
2115    fn contention_groups_singletons_for_different_wallets() {
2116        let events = vec![
2117            buy(
2118                "CL",
2119                datetime!(2026-05-01 00:00:00 UTC),
2120                cold(),
2121                LOT,
2122                dec!(5000),
2123            ),
2124            buy(
2125                "HL",
2126                datetime!(2026-05-01 00:00:00 UTC),
2127                hot(),
2128                LOT,
2129                dec!(5000),
2130            ),
2131            sell(
2132                "DC",
2133                datetime!(2026-06-01 00:00:00 UTC),
2134                cold(),
2135                LOT,
2136                dec!(10000),
2137            ),
2138            sell(
2139                "DH",
2140                datetime!(2026-06-01 00:00:00 UTC),
2141                hot(),
2142                LOT,
2143                dec!(10000),
2144            ),
2145        ];
2146        let prices = StaticPrices::default();
2147        let mut targets = vec![
2148            (eid("DC"), cold(), date!(2026 - 06 - 01), LOT),
2149            (eid("DH"), hot(), date!(2026 - 06 - 01), LOT),
2150        ];
2151        targets.sort_by(|a, b| a.0.cmp(&b.0));
2152        let groups = contention_groups(&events, &prices, &cfg(), &targets);
2153        assert_eq!(groups.len(), 2, "different wallets → two singleton groups");
2154        assert!(groups.iter().all(|g| g.len() == 1));
2155    }
2156
2157    /// A contended group whose joint enumeration would exceed `GROUP_COMBO_BOUND` → `None` (caller then
2158    /// flags `ContentionUnenumerated`). Four same-wallet 1-lot sells over a 10-lot pool: 10·9·8·7 = 5040.
2159    #[test]
2160    fn group_candidate_assignments_none_beyond_bound() {
2161        let mut events: Vec<LedgerEvent> = (0..10)
2162            .map(|i| {
2163                buy(
2164                    &format!("L{i:02}"),
2165                    datetime!(2026-05-01 00:00:00 UTC),
2166                    cold(),
2167                    LOT,
2168                    dec!(5000),
2169                )
2170            })
2171            .collect();
2172        let dates = [
2173            date!(2026 - 06 - 01),
2174            date!(2026 - 06 - 02),
2175            date!(2026 - 06 - 03),
2176            date!(2026 - 06 - 04),
2177        ];
2178        for (k, d) in dates.iter().enumerate() {
2179            events.push(sell(
2180                &format!("D{k}"),
2181                datetime!(2026-06-01 00:00:00 UTC).replace_date(*d),
2182                cold(),
2183                LOT,
2184                dec!(10000),
2185            ));
2186        }
2187        let prices = StaticPrices::default();
2188        let members: Vec<_> = (0..4)
2189            .map(|k| (eid(&format!("D{k}")), cold(), dates[k], LOT))
2190            .collect();
2191        let res = group_candidate_assignments(&events, &prices, &cfg(), &members);
2192        assert!(res.is_none(), "joint count 5040 > GROUP_COMBO_BOUND ⇒ None");
2193    }
2194}