btctax_core/project/evaluate.rs
1//! §A.6 side-effect-free evaluate entrypoint.
2//!
3//! `evaluate_disposal` folds a candidate disposal (existing-ledger **or** synthetic/hypothetical)
4//! plus a candidate lot selection through the same `consume`/validation/scoring path as the real
5//! projection, then returns the resulting legs/gains/lots WITHOUT mutating the ledger.
6//!
7//! Pattern: clone → append-synthetic-if-needed → fold → read → discard (mirrors
8//! `transition::universal_snapshot`'s clone-fold-discard approach).
9//!
10//! `--proceeds` is required when no dataset price exists for the candidate date (Mode-2 future
11//! disposals have no price entry). For an existing disposal the proceeds already live in the event.
12use crate::conventions::{Sat, Usd};
13use crate::event::{DisposeKind, LedgerEvent, LotPick};
14use crate::identity::{EventId, SourceRef, WalletId};
15use crate::price::{fmv_of, PriceProvider};
16use crate::project::fold::fold;
17use crate::project::resolve::{resolve, Eff, Op};
18use crate::state::{Blocker, BlockerKind, DisposalLeg, Lot, Term};
19use crate::{ProjectionConfig, TaxDate};
20use time::UtcOffset;
21
22/// A candidate disposal to be evaluated (without persisting anything).
23///
24/// - `existing_event = Some(id)` — re-score an event already in the ledger with a candidate
25/// selection. The event's own proceeds AND its resolved principal sat are used; both
26/// `proceeds` and `sat` on the candidate are ignored (M2: an injected selection is validated
27/// against the event's resolved principal, so a wrong `candidate.sat` cannot mis-score).
28/// - `existing_event = None` — a synthetic/hypothetical disposal (Mode-2 consultation). The
29/// engine appends a temporary `Op::Dispose` with the given `proceeds` (or FMV from the
30/// dataset when `proceeds` is `None`), folds, and discards the result.
31#[derive(Debug, Clone)]
32pub struct CandidateDisposal {
33 /// `Some(id)` → score an existing disposal; `None` → synthetic (Mode-2).
34 pub existing_event: Option<EventId>,
35 pub wallet: WalletId,
36 pub date: TaxDate,
37 pub sat: Sat,
38 pub kind: DisposeKind,
39 /// Explicit proceeds (wins over FMV). Required for synthetic disposals on dates with no
40 /// price entry — `evaluate_disposal` returns `Err(ProceedsRequired)` when both this and
41 /// the dataset FMV are absent.
42 pub proceeds: Option<Usd>,
43}
44
45/// The result of scoring a candidate disposal.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct EvaluateOutcome {
48 /// Disposal legs for this candidate's disposal event, in fold order.
49 pub legs: Vec<DisposalLeg>,
50 /// Σ gain on short-term legs.
51 pub st_gain: Usd,
52 /// Σ gain on long-term legs.
53 pub lt_gain: Usd,
54 /// Remaining lots after the fold (the full post-fold pool — allows the caller to inspect
55 /// what remains available for future disposals).
56 pub lots_after: Vec<Lot>,
57 /// Hard/advisory blockers attributed to this candidate's disposal event, plus any
58 /// principal-conservation violation in the candidate selection.
59 pub blockers: Vec<Blocker>,
60}
61
62/// Error returned by `evaluate_disposal` when the fold cannot proceed.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum EvaluateError {
65 /// A synthetic disposal on a date with no dataset price requires an explicit `--proceeds`.
66 ProceedsRequired,
67 /// `existing_event` named an event that does not resolve to a method-honoring disposal op
68 /// (Dispose / GiftOut / Donate / SelfTransfer) in the current timeline.
69 UnknownExistingDisposal,
70}
71
72/// The resolved principal sat of a method-honoring disposal op (Dispose / GiftOut / Donate /
73/// SelfTransfer), or `None` for any other op. This is the basis an injected `LotSelection` must
74/// conserve against (M2): for an existing event it is the event's RESOLVED principal, never the
75/// caller-supplied `candidate.sat`.
76fn honoring_sat(op: &Op) -> Option<Sat> {
77 match op {
78 Op::Dispose { sat, .. }
79 | Op::GiftOut { sat, .. }
80 | Op::Donate { sat, .. }
81 | Op::SelfTransfer { sat, .. } => Some(*sat),
82 _ => None,
83 }
84}
85
86/// Side-effect-free evaluation of a candidate disposal + lot selection.
87///
88/// **No mutation:** events/prices/config are borrowed read-only; `resolve` + `fold` operate
89/// on an owned clone of the `Resolution`; the resulting `LedgerState` is read then discarded.
90///
91/// **Proceeds resolution (synthetic path only):**
92/// 1. `candidate.proceeds` wins when `Some`.
93/// 2. Dataset FMV (from `prices`) is used when `proceeds` is `None` and a price exists.
94/// 3. `Err(ProceedsRequired)` is returned when both are absent (typical for future dates).
95///
96/// For an existing disposal (`existing_event = Some(id)`) the proceeds already live in the
97/// event's `Op::Dispose { proceeds, .. }`; step 1-3 are not consulted.
98pub fn evaluate_disposal(
99 events: &[LedgerEvent],
100 prices: &dyn PriceProvider,
101 config: &ProjectionConfig,
102 candidate: &CandidateDisposal,
103 selection: Option<&[LotPick]>,
104) -> Result<EvaluateOutcome, EvaluateError> {
105 let mut res = resolve(events, prices, config);
106
107 // `principal` is the RESOLVED principal sat the fold will actually consume for this disposal —
108 // the basis an injected selection MUST conserve against (M2). For an existing event it is the
109 // event's own resolved sat (NOT `candidate.sat`); for a synthetic disposal it is the
110 // `candidate.sat` we inject below.
111 let (target_id, principal): (EventId, Sat) = match &candidate.existing_event {
112 Some(id) => {
113 // Verify the event resolves to a method-honoring disposal op in the current timeline
114 // and capture its resolved principal sat. (M2: a wrong `candidate.sat` must not be
115 // able to silently under/over-consume — we validate against this resolved value, not
116 // `candidate.sat`.)
117 let sat = res
118 .timeline
119 .iter()
120 .find(|e| &e.id == id)
121 .and_then(|e| honoring_sat(&e.op))
122 .ok_or(EvaluateError::UnknownExistingDisposal)?;
123 (id.clone(), sat)
124 }
125 None => {
126 // Synthetic disposal: resolve proceeds (explicit > FMV > error).
127 let proceeds = match candidate.proceeds {
128 Some(p) => p,
129 None => fmv_of(prices, candidate.date, candidate.sat)
130 .ok_or(EvaluateError::ProceedsRequired)?,
131 };
132 // Use a reserved sentinel seq (u64::MAX) as the synthetic event id.
133 // Real decision sequences start at 0 and are assigned by `append_decision`; u64::MAX
134 // is unreachable in practice and is never persisted (NFR4: no I/O in this path).
135 let id = EventId::Decision { seq: u64::MAX };
136 // midnight().assume_utc() gives OffsetDateTime at UTC 00:00:00 on the candidate date;
137 // tax_date(utc, UTC) == candidate.date exactly (§6.1 day-granularity convention).
138 let utc = candidate.date.midnight().assume_utc();
139 res.timeline.push(Eff {
140 id: id.clone(),
141 utc,
142 tz: UtcOffset::UTC,
143 src_priority: 0,
144 src_ref: SourceRef::new("__synthetic__"),
145 wallet: Some(candidate.wallet.clone()),
146 op: Op::Dispose {
147 sat: candidate.sat,
148 proceeds,
149 fee_usd: Usd::ZERO,
150 fee_sat: None,
151 kind: candidate.kind,
152 },
153 pseudo: false, // synthetic optimizer candidate — unrelated to pseudo-reconcile mode
154 });
155 (id, candidate.sat)
156 }
157 };
158
159 // Inject the candidate selection (overrides any persisted selection for this event), after
160 // mirroring resolve's principal-conservation guard: Σpick.sat MUST equal the RESOLVED
161 // principal (M2 — for an existing event this is the event's own sat, never `candidate.sat`).
162 let mut extra: Vec<Blocker> = Vec::new();
163 if let Some(picks) = selection {
164 let picked: Sat = picks.iter().map(|p| p.sat).sum();
165 if picked != principal {
166 extra.push(Blocker {
167 kind: BlockerKind::LotSelectionInvalid,
168 event: Some(target_id.clone()),
169 detail: format!(
170 "candidate selection must conserve principal: {picked} != {principal}"
171 ),
172 });
173 } else {
174 res.selections.insert(target_id.clone(), picks.to_vec());
175 }
176 }
177
178 // Fold through the same consume/validation/scoring path as the real projection.
179 // The resulting `LedgerState` is read then immediately discarded — no I/O, no persistence.
180 let state = fold(res, prices, config);
181
182 // Extract legs, gains, and blockers attributed to the candidate event.
183 let legs: Vec<DisposalLeg> = state
184 .disposals
185 .iter()
186 .filter(|d| d.event == target_id)
187 .flat_map(|d| d.legs.clone())
188 .collect();
189 let st_gain: Usd = legs
190 .iter()
191 .filter(|l| l.term == Term::ShortTerm)
192 .map(|l| l.gain)
193 .sum();
194 let lt_gain: Usd = legs
195 .iter()
196 .filter(|l| l.term == Term::LongTerm)
197 .map(|l| l.gain)
198 .sum();
199 let mut blockers: Vec<Blocker> = state
200 .blockers
201 .iter()
202 .filter(|b| b.event.as_ref() == Some(&target_id))
203 .cloned()
204 .collect();
205 blockers.extend(extra);
206
207 Ok(EvaluateOutcome {
208 legs,
209 st_gain,
210 lt_gain,
211 lots_after: state.lots,
212 blockers,
213 })
214}