Skip to main content

wowlab_engine_ports/
decision_trace.rs

1//! Decision-trace port: typed channel from the rotation engine to consumers.
2
3use std::sync::{Arc, Mutex};
4
5#[cfg(feature = "wasm")]
6use tsify::Tsify;
7
8/// Full set of decisions captured during a single `evaluate()` call.
9#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
10#[cfg_attr(feature = "wasm", derive(Tsify))]
11#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
12#[serde(rename_all = "camelCase")]
13pub struct DecisionTrace {
14    pub decisions: Vec<Decision>,
15}
16
17/// One decision recorded for a single `evaluate()` call.
18#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
19#[cfg_attr(feature = "wasm", derive(Tsify))]
20#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
21#[serde(rename_all = "camelCase")]
22pub struct Decision {
23    pub time_ms: u32,
24    pub list_id: String,
25    pub fired_action_index: Option<usize>,
26    pub evaluations: Vec<ActionEvaluation>,
27    pub nested: Vec<Decision>,
28}
29
30/// Per-action evaluation outcome.
31#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
32#[cfg_attr(feature = "wasm", derive(Tsify))]
33#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
34#[serde(rename_all = "camelCase")]
35pub struct ActionEvaluation {
36    pub list_id: String,
37    pub action_index: usize,
38    pub status: EvaluationStatus,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub rejection_reason: Option<String>,
41}
42
43/// Why an action did or did not fire.
44#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
45#[cfg_attr(feature = "wasm", derive(Tsify))]
46#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum EvaluationStatus {
50    Fired,
51    Executed,
52    Rejected,
53    NotReached,
54    Disabled,
55}
56
57/// Recipient of [`Decision`]s emitted by the interpreter backend.
58pub trait DecisionTraceSink: std::fmt::Debug + Send + Sync {
59    fn record(&self, decision: Decision);
60}
61
62impl<T> DecisionTraceSink for Arc<T>
63where
64    T: DecisionTraceSink + ?Sized,
65{
66    fn record(&self, decision: Decision) {
67        (**self).record(decision);
68    }
69}
70
71/// Cloneable trace destination with shared ownership hidden at the port boundary.
72#[derive(Clone, Debug)]
73pub struct DecisionTraceTarget(Arc<dyn DecisionTraceSink>);
74
75impl DecisionTraceTarget {
76    /// Wraps a concrete trace sink.
77    pub fn new(sink: impl DecisionTraceSink + 'static) -> Self {
78        Self(Arc::new(sink))
79    }
80}
81
82impl DecisionTraceSink for DecisionTraceTarget {
83    fn record(&self, decision: Decision) {
84        self.0.record(decision);
85    }
86}
87
88/// In-memory sink used by tests and the WASM bridge.
89#[derive(Clone, Debug, Default)]
90pub struct VecSink {
91    inner: Arc<Mutex<Vec<Decision>>>,
92}
93
94impl VecSink {
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Removes and returns all recorded decisions.
101    ///
102    /// # Panics
103    ///
104    /// Panics if another thread poisoned the sink mutex.
105    #[must_use]
106    pub fn drain(&self) -> Vec<Decision> {
107        let mut guard = self
108            .inner
109            .lock()
110            .expect("decision-trace sink mutex poisoned");
111
112        std::mem::take(&mut *guard)
113    }
114
115    /// Returns a copy of all recorded decisions without removing them.
116    ///
117    /// # Panics
118    ///
119    /// Panics if another thread poisoned the sink mutex.
120    #[must_use]
121    pub fn snapshot(&self) -> Vec<Decision> {
122        self.inner
123            .lock()
124            .expect("decision-trace sink mutex poisoned")
125            .clone()
126    }
127}
128
129impl DecisionTraceSink for VecSink {
130    fn record(&self, decision: Decision) {
131        self.inner
132            .lock()
133            .expect("decision-trace sink mutex poisoned")
134            .push(decision);
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use googletest::prelude::*;
141
142    use super::*;
143
144    fn sample_decision() -> Decision {
145        Decision {
146            time_ms: 0,
147            list_id: "a".into(),
148            fired_action_index: None,
149            evaluations: vec![],
150            nested: vec![],
151        }
152    }
153
154    #[gtest]
155    fn vecsink_snapshot_is_nondestructive_drain_is_destructive() -> Result<()> {
156        let sink = VecSink::new();
157
158        sink.record(sample_decision());
159        sink.record(sample_decision());
160
161        verify_that!(sink.snapshot(), len(eq(2)))?;
162        verify_that!(sink.snapshot(), len(eq(2)))?;
163
164        verify_that!(sink.drain(), len(eq(2)))?;
165        verify_that!(sink.drain(), len(eq(0)))?;
166
167        verify_that!(sink.snapshot(), len(eq(0)))
168    }
169}