Skip to main content

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}