Skip to main content

qs_backtest/
ledger.rs

1//! Terminal action lifecycle accounting.
2//!
3//! A [`LifecycleLedger`] accepts only terminal dispositions and rejects a
4//! second disposition for the same action id. Its custom deserializer applies
5//! the same validation, so the one-terminal-record invariant also holds for
6//! artifacts loaded from storage.
7
8use std::error::Error;
9use std::fmt;
10
11use chrono::NaiveDateTime;
12use serde::{Deserialize, Deserializer, Serialize};
13
14/// Terminal result of applying an action to a runner/engine.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum ActionDispositionStatus {
18    /// The action was accepted and its effects were processed.
19    #[default]
20    Applied,
21    /// The action was valid but intentionally had no effect.
22    Skipped,
23    /// Validation or current lifecycle state rejected the action.
24    Rejected,
25    /// An unexpected execution/integration error prevented application.
26    Failed,
27}
28
29/// Exactly one terminal accounting record for one runner action.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ActionDisposition {
33    pub action_id: String,
34    /// Stable caller-defined action category (for example, `open` or `close`).
35    pub action_kind: Option<String>,
36    pub signal_ts: Option<NaiveDateTime>,
37    pub effective_ts: Option<NaiveDateTime>,
38    pub status: ActionDispositionStatus,
39    /// Machine-stable or human-readable reason. Usually absent for `Applied`.
40    pub reason: Option<String>,
41    /// Position ids affected by bulk actions are retained in deterministic order.
42    pub position_ids: Vec<String>,
43}
44
45impl Default for ActionDisposition {
46    fn default() -> Self {
47        Self {
48            action_id: String::new(),
49            action_kind: None,
50            signal_ts: None,
51            effective_ts: None,
52            status: ActionDispositionStatus::Applied,
53            reason: None,
54            position_ids: Vec::new(),
55        }
56    }
57}
58
59impl ActionDisposition {
60    pub fn new(action_id: impl Into<String>, status: ActionDispositionStatus) -> Self {
61        Self {
62            action_id: action_id.into(),
63            status,
64            ..Self::default()
65        }
66    }
67
68    pub fn applied(action_id: impl Into<String>) -> Self {
69        Self::new(action_id, ActionDispositionStatus::Applied)
70    }
71
72    pub fn skipped(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
73        Self::with_reason(action_id, ActionDispositionStatus::Skipped, reason)
74    }
75
76    pub fn rejected(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
77        Self::with_reason(action_id, ActionDispositionStatus::Rejected, reason)
78    }
79
80    pub fn failed(action_id: impl Into<String>, reason: impl Into<String>) -> Self {
81        Self::with_reason(action_id, ActionDispositionStatus::Failed, reason)
82    }
83
84    fn with_reason(
85        action_id: impl Into<String>,
86        status: ActionDispositionStatus,
87        reason: impl Into<String>,
88    ) -> Self {
89        Self {
90            action_id: action_id.into(),
91            status,
92            reason: Some(reason.into()),
93            ..Self::default()
94        }
95    }
96
97    /// All statuses represented by this type are terminal by construction.
98    pub const fn is_terminal(&self) -> bool {
99        true
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum LedgerError {
105    EmptyActionId,
106    DuplicateTerminalRecord { action_id: String },
107}
108
109impl fmt::Display for LedgerError {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::EmptyActionId => write!(formatter, "action id must not be empty"),
113            Self::DuplicateTerminalRecord { action_id } => {
114                write!(
115                    formatter,
116                    "action '{action_id}' already has a terminal record"
117                )
118            }
119        }
120    }
121}
122
123impl Error for LedgerError {}
124
125/// Ordered terminal action records with a uniqueness invariant on `action_id`.
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
127pub struct LifecycleLedger {
128    records: Vec<ActionDisposition>,
129}
130
131impl LifecycleLedger {
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    pub fn from_records(
137        records: impl IntoIterator<Item = ActionDisposition>,
138    ) -> Result<Self, LedgerError> {
139        let mut ledger = Self::new();
140        for record in records {
141            ledger.record(record)?;
142        }
143        Ok(ledger)
144    }
145
146    /// Append a terminal disposition, rejecting empty or previously finalized ids.
147    pub fn record(&mut self, disposition: ActionDisposition) -> Result<(), LedgerError> {
148        if disposition.action_id.is_empty() {
149            return Err(LedgerError::EmptyActionId);
150        }
151        if self.contains(&disposition.action_id) {
152            return Err(LedgerError::DuplicateTerminalRecord {
153                action_id: disposition.action_id,
154            });
155        }
156        self.records.push(disposition);
157        Ok(())
158    }
159
160    pub fn contains(&self, action_id: &str) -> bool {
161        self.records
162            .iter()
163            .any(|record| record.action_id == action_id)
164    }
165
166    pub fn get(&self, action_id: &str) -> Option<&ActionDisposition> {
167        self.records
168            .iter()
169            .find(|record| record.action_id == action_id)
170    }
171
172    pub fn iter(&self) -> impl ExactSizeIterator<Item = &ActionDisposition> {
173        self.records.iter()
174    }
175
176    pub fn as_slice(&self) -> &[ActionDisposition] {
177        &self.records
178    }
179
180    pub fn len(&self) -> usize {
181        self.records.len()
182    }
183
184    pub fn is_empty(&self) -> bool {
185        self.records.is_empty()
186    }
187
188    pub fn count(&self, status: ActionDispositionStatus) -> usize {
189        self.records
190            .iter()
191            .filter(|record| record.status == status)
192            .count()
193    }
194}
195
196#[derive(Deserialize, Default)]
197#[serde(default)]
198struct LifecycleLedgerWire {
199    records: Vec<ActionDisposition>,
200}
201
202impl<'de> Deserialize<'de> for LifecycleLedger {
203    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
204    where
205        D: Deserializer<'de>,
206    {
207        let wire = LifecycleLedgerWire::deserialize(deserializer)?;
208        Self::from_records(wire.records).map_err(serde::de::Error::custom)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use chrono::NaiveDate;
216
217    fn ts() -> NaiveDateTime {
218        NaiveDate::from_ymd_opt(2026, 2, 3)
219            .unwrap()
220            .and_hms_opt(4, 5, 6)
221            .unwrap()
222    }
223
224    #[test]
225    fn records_exactly_one_terminal_disposition_per_action() {
226        let mut ledger = LifecycleLedger::new();
227        let mut applied = ActionDisposition::applied("action-1");
228        applied.action_kind = Some("open".into());
229        applied.signal_ts = Some(ts());
230        applied.effective_ts = Some(ts());
231        applied.position_ids.push("position-1".into());
232
233        ledger.record(applied).unwrap();
234        let duplicate = ledger.record(ActionDisposition::rejected("action-1", "too late"));
235
236        assert_eq!(
237            duplicate,
238            Err(LedgerError::DuplicateTerminalRecord {
239                action_id: "action-1".into()
240            })
241        );
242        assert_eq!(ledger.len(), 1);
243        assert_eq!(ledger.get("action-1").unwrap().position_ids, ["position-1"]);
244    }
245
246    #[test]
247    fn rejects_empty_action_ids() {
248        let mut ledger = LifecycleLedger::new();
249        assert_eq!(
250            ledger.record(ActionDisposition::applied("")),
251            Err(LedgerError::EmptyActionId)
252        );
253        assert!(ledger.is_empty());
254    }
255
256    #[test]
257    fn status_helpers_capture_reasons_and_counts() {
258        let mut ledger = LifecycleLedger::new();
259        ledger.record(ActionDisposition::applied("a")).unwrap();
260        ledger
261            .record(ActionDisposition::skipped("b", "no matching position"))
262            .unwrap();
263        ledger
264            .record(ActionDisposition::rejected("c", "invalid size"))
265            .unwrap();
266        ledger
267            .record(ActionDisposition::failed("d", "adapter failure"))
268            .unwrap();
269
270        assert_eq!(ledger.count(ActionDispositionStatus::Applied), 1);
271        assert_eq!(ledger.count(ActionDispositionStatus::Skipped), 1);
272        assert_eq!(ledger.count(ActionDispositionStatus::Rejected), 1);
273        assert_eq!(ledger.count(ActionDispositionStatus::Failed), 1);
274        assert_eq!(
275            ledger.get("c").unwrap().reason.as_deref(),
276            Some("invalid size")
277        );
278        assert!(ledger.iter().all(ActionDisposition::is_terminal));
279    }
280
281    #[test]
282    fn serde_roundtrip_preserves_order() {
283        let ledger = LifecycleLedger::from_records([
284            ActionDisposition::applied("z"),
285            ActionDisposition::rejected("a", "bad request"),
286        ])
287        .unwrap();
288        let json = serde_json::to_string(&ledger).unwrap();
289        let decoded: LifecycleLedger = serde_json::from_str(&json).unwrap();
290
291        assert_eq!(decoded, ledger);
292        let ids: Vec<_> = decoded
293            .iter()
294            .map(|record| record.action_id.as_str())
295            .collect();
296        assert_eq!(ids, ["z", "a"]);
297    }
298
299    #[test]
300    fn deserialization_cannot_bypass_uniqueness_invariant() {
301        let json = r#"{
302            "records": [
303                {"action_id":"a","status":"applied"},
304                {"action_id":"a","status":"rejected","reason":"duplicate"}
305            ]
306        }"#;
307        let error = serde_json::from_str::<LifecycleLedger>(json).unwrap_err();
308        assert!(error.to_string().contains("already has a terminal record"));
309    }
310
311    #[test]
312    fn additive_disposition_fields_have_serde_defaults() {
313        let disposition: ActionDisposition = serde_json::from_str(r#"{"action_id":"a"}"#).unwrap();
314        assert_eq!(disposition.status, ActionDispositionStatus::Applied);
315        assert_eq!(disposition.reason, None);
316        assert!(disposition.position_ids.is_empty());
317
318        let ledger: LifecycleLedger = serde_json::from_str("{}").unwrap();
319        assert!(ledger.is_empty());
320    }
321}