Skip to main content

telltale_machine/
trace.rs

1//! Trace normalization utilities.
2//!
3//! Session-local normalization reassigns ticks based on per-session
4//! event counts. Global ticks are preserved for non-session events.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::engine::ObsEvent;
11use crate::session::SessionId;
12
13fn default_trace_schema_version() -> u32 {
14    1
15}
16
17/// Version identifier for normalized trace payloads.
18pub const TRACE_NORMALIZATION_SCHEMA_VERSION: u32 = 1;
19
20/// Versioned normalized trace payload.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct NormalizedTraceV1 {
23    /// Schema version for normalized trace encoding.
24    #[serde(default = "default_trace_schema_version")]
25    pub schema_version: u32,
26    /// Session-normalized observable events.
27    pub events: Vec<ObsEvent>,
28}
29
30/// Extract the session id from an observable event, if present.
31#[must_use]
32pub fn obs_session(ev: &ObsEvent) -> Option<SessionId> {
33    match ev {
34        ObsEvent::Sent { session, .. }
35        | ObsEvent::Received { session, .. }
36        | ObsEvent::Opened { session, .. }
37        | ObsEvent::Closed { session, .. }
38        | ObsEvent::SessionTerminal { session, .. }
39        | ObsEvent::Acquired { session, .. }
40        | ObsEvent::Released { session, .. }
41        | ObsEvent::Transferred { session, .. }
42        | ObsEvent::Forked { session, .. }
43        | ObsEvent::Joined { session, .. }
44        | ObsEvent::Aborted { session, .. }
45        | ObsEvent::CancellationRequested { session, .. }
46        | ObsEvent::Cancelled { session, .. }
47        | ObsEvent::Tagged { session, .. }
48        | ObsEvent::Checked { session, .. } => Some(*session),
49        ObsEvent::Offered { edge, .. } | ObsEvent::Chose { edge, .. } => Some(edge.sid),
50        ObsEvent::EpochAdvanced { sid, .. } => Some(*sid),
51        ObsEvent::Halted { .. }
52        | ObsEvent::Invoked { .. }
53        | ObsEvent::TimeoutIssued { .. }
54        | ObsEvent::FailureBranchEntered { .. }
55        | ObsEvent::Faulted { .. }
56        | ObsEvent::OutputConditionChecked { .. } => None,
57    }
58}
59
60/// Clone an event with a new tick value.
61#[must_use]
62pub fn with_tick(ev: &ObsEvent, tick: u64) -> ObsEvent {
63    let mut out = ev.clone();
64    set_obs_event_tick(&mut out, tick);
65    out
66}
67
68#[allow(clippy::too_many_lines)]
69fn set_obs_event_tick(out: &mut ObsEvent, tick: u64) {
70    match out {
71        ObsEvent::Sent {
72            tick: event_tick, ..
73        }
74        | ObsEvent::Received {
75            tick: event_tick, ..
76        }
77        | ObsEvent::Offered {
78            tick: event_tick, ..
79        }
80        | ObsEvent::Chose {
81            tick: event_tick, ..
82        }
83        | ObsEvent::Opened {
84            tick: event_tick, ..
85        }
86        | ObsEvent::Closed {
87            tick: event_tick, ..
88        }
89        | ObsEvent::SessionTerminal {
90            tick: event_tick, ..
91        }
92        | ObsEvent::EpochAdvanced {
93            tick: event_tick, ..
94        }
95        | ObsEvent::Halted {
96            tick: event_tick, ..
97        }
98        | ObsEvent::Invoked {
99            tick: event_tick, ..
100        }
101        | ObsEvent::Acquired {
102            tick: event_tick, ..
103        }
104        | ObsEvent::Released {
105            tick: event_tick, ..
106        }
107        | ObsEvent::Transferred {
108            tick: event_tick, ..
109        }
110        | ObsEvent::Forked {
111            tick: event_tick, ..
112        }
113        | ObsEvent::Joined {
114            tick: event_tick, ..
115        }
116        | ObsEvent::Aborted {
117            tick: event_tick, ..
118        }
119        | ObsEvent::CancellationRequested {
120            tick: event_tick, ..
121        }
122        | ObsEvent::Cancelled {
123            tick: event_tick, ..
124        }
125        | ObsEvent::Tagged {
126            tick: event_tick, ..
127        }
128        | ObsEvent::Checked {
129            tick: event_tick, ..
130        }
131        | ObsEvent::FailureBranchEntered {
132            tick: event_tick, ..
133        }
134        | ObsEvent::TimeoutIssued {
135            tick: event_tick, ..
136        }
137        | ObsEvent::Faulted {
138            tick: event_tick, ..
139        }
140        | ObsEvent::OutputConditionChecked {
141            tick: event_tick, ..
142        } => *event_tick = tick,
143    }
144}
145
146/// Normalize a trace by assigning session-local ticks.
147#[must_use]
148pub fn normalize_trace(trace: &[ObsEvent]) -> Vec<ObsEvent> {
149    let mut counters: BTreeMap<SessionId, u64> = BTreeMap::new();
150    let mut out = Vec::with_capacity(trace.len());
151    for ev in trace {
152        if let Some(session) = obs_session(ev) {
153            let counter = counters.entry(session).or_insert(0);
154            let local_tick = *counter;
155            *counter += 1;
156            out.push(with_tick(ev, local_tick));
157        } else {
158            out.push(ev.clone());
159        }
160    }
161    out
162}
163
164/// Clone a trace without normalization.
165#[must_use]
166pub fn strict_trace(trace: &[ObsEvent]) -> Vec<ObsEvent> {
167    trace.to_vec()
168}
169
170/// Normalize a trace and wrap it in a versioned payload.
171#[must_use]
172pub fn normalize_trace_v1(trace: &[ObsEvent]) -> NormalizedTraceV1 {
173    NormalizedTraceV1 {
174        schema_version: TRACE_NORMALIZATION_SCHEMA_VERSION,
175        events: normalize_trace(trace),
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::session::Edge;
183
184    #[test]
185    #[allow(clippy::as_conversions)]
186    fn normalize_trace_memory_is_bounded_at_10k_events() {
187        let mut trace = Vec::with_capacity(10_000);
188        for i in 0..10_000usize {
189            let sid = i % 32;
190            let tick = i as u64;
191            if i % 2 == 0 {
192                trace.push(ObsEvent::Sent {
193                    tick,
194                    edge: Edge::new(sid, "A", "B"),
195                    session: sid,
196                    from: "A".to_string(),
197                    to: "B".to_string(),
198                    label: "m".to_string(),
199                });
200            } else {
201                trace.push(ObsEvent::Received {
202                    tick,
203                    edge: Edge::new(sid, "B", "A"),
204                    session: sid,
205                    from: "B".to_string(),
206                    to: "A".to_string(),
207                    label: "m".to_string(),
208                });
209            }
210        }
211
212        let normalized = normalize_trace(&trace);
213        assert_eq!(normalized.len(), trace.len());
214        assert!(
215            normalized.capacity() <= trace.len() + 1,
216            "normalize_trace should allocate O(n) space without capacity blow-up"
217        );
218    }
219}