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 <_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 ¤t,
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 <_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}