Skip to main content

zero_operator_state/
classifier.rs

1//! Pure event → state classifier.
2//!
3//! The classifier is deterministic: given the same event log and the
4//! same `now`, it produces identical output. That is the property
5//! that makes snapshot tests cheap, replay meaningful, and bugs
6//! reproducible.
7//!
8//! ## What the classifier does
9//!
10//! 1. Ingest [`Event`]s in timestamp order.
11//! 2. Compute the [`StateVector`] fields from sliding-window reductions.
12//! 3. Apply the label rules from **Addendum A §2.3 and §10.2** to
13//!    produce a [`Label`].
14//! 4. Hand both to a [`Snapshot`].
15//!
16//! ## What the classifier deliberately does not do
17//!
18//! - It does not talk to the engine.
19//! - It does not read or write files.
20//! - It does not emit friction side effects. `zero-commands` applies
21//!   the friction gate using [`crate::FrictionGate`], not this type.
22//! - It does not decide what to render. `zero-tui` reads the
23//!   [`Snapshot`].
24//!
25//! Separation keeps the classifier exhaustively testable and
26//! snapshot-safe.
27
28use chrono::{DateTime, Duration, Utc};
29
30use crate::events::{Event, EventKind, Outcome};
31use crate::friction::RiskContext;
32use crate::label::Label;
33use crate::snapshot::Snapshot;
34use crate::vector::{Deviation, LossReaction, ReEntry, Session, SleepProxy, StateVector, Velocity};
35
36/// Incremental classifier. Feed events in, snapshot out.
37#[derive(Debug, Default, Clone)]
38pub struct Classifier {
39    events: Vec<Event>,
40    version: u64,
41    on_break_since: Option<DateTime<Utc>>,
42    last_break_ended_at: Option<DateTime<Utc>>,
43    session_started_at: Option<DateTime<Utc>>,
44    last_loss_at: Option<DateTime<Utc>>,
45    last_loss_symbol: Option<String>,
46}
47
48impl Classifier {
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Append an event. Events must be monotonically non-decreasing
55    /// in `ts`; the classifier does not re-sort.
56    pub fn push(&mut self, event: Event) {
57        match &event.kind {
58            EventKind::SessionStarted => self.session_started_at = Some(event.ts),
59            EventKind::BreakStarted { .. } => self.on_break_since = Some(event.ts),
60            EventKind::BreakEnded => {
61                self.on_break_since = None;
62                self.last_break_ended_at = Some(event.ts);
63            }
64            EventKind::TradeClosed {
65                outcome: Outcome::Loss,
66                symbol,
67                ..
68            } => {
69                self.last_loss_at = Some(event.ts);
70                self.last_loss_symbol = Some(symbol.clone());
71            }
72            // `Conviction` (and every other non-vector-affecting
73            // kind) falls through here. It is a calibration
74            // ingredient, not a state-vector ingredient: it
75            // describes how the operator *felt* about a past
76            // trade, not their current tempo, deviation, or
77            // session state. The classifier keeps the event in
78            // the log (so deterministic replay still
79            // reconstructs the same history — see
80            // `classify_is_deterministic_over_the_same_log`)
81            // but does not reach into any rolling window for
82            // it. Downstream calibration consumers join on
83            // `trade_id`; the classifier itself never needs to.
84            _ => {}
85        }
86        self.events.push(event);
87        self.version = self.version.wrapping_add(1);
88    }
89
90    /// Compute a fresh snapshot as of `now`.
91    ///
92    /// Uses [`Snapshot::new`], which caps friction at L2. Callers
93    /// with engine context (the dispatcher, the TUI's operator-
94    /// state refresh path) should use
95    /// [`Self::classify_with_risk`] to reach L3/L4.
96    #[must_use]
97    pub fn classify(&self, now: DateTime<Utc>) -> Snapshot {
98        let vector = self.compute_vector(now);
99        let label = label_for(&vector);
100        Snapshot::new(label, vector, now, self.version)
101    }
102
103    /// Compute a fresh snapshot as of `now`, folding
104    /// engine-reported risk context into the friction level.
105    ///
106    /// The event-pure part of the classifier is unchanged — `risk`
107    /// participates only at the final `Snapshot` construction via
108    /// [`Snapshot::new_with_risk`]. Passing
109    /// [`RiskContext::default`] is equivalent to calling
110    /// [`Self::classify`].
111    ///
112    /// This entrypoint is what the TUI calls on every tick once
113    /// the engine mirror has a live `Risk` payload; the
114    /// additional O(1) work is a pair of integer comparisons and
115    /// a branch, so the p95 budget from
116    /// `classifier_tick_under_budget_on_typical_load` is
117    /// unaffected.
118    #[must_use]
119    pub fn classify_with_risk(&self, now: DateTime<Utc>, risk: RiskContext) -> Snapshot {
120        let vector = self.compute_vector(now);
121        let label = label_for(&vector);
122        Snapshot::new_with_risk(label, vector, now, self.version, risk)
123    }
124
125    #[allow(clippy::too_many_lines)] // windowed reductions deliberately inlined for clarity
126    fn compute_vector(&self, now: DateTime<Utc>) -> StateVector {
127        let h1 = now - Duration::hours(1);
128        let h4 = now - Duration::hours(4);
129        let day = now - Duration::hours(24);
130
131        // Decisions are any DecisionMade event. Frozen while on break.
132        let mut decisions = (0u32, 0u32, 0u32);
133        let mut verdicts_shown_10 = 0u32;
134        let mut verdicts_shown_50 = 0u32;
135        let mut overrides_10 = 0u32;
136        let mut overrides_50 = 0u32;
137        let mut fastest_loss_reaction_ms: u64 = u64::MAX;
138        let mut loss_reactions: Vec<u64> = Vec::new();
139        let mut re_entry = ReEntry::default();
140
141        // Walk the event log newest-first so windowed counts are
142        // cheap. We stop once all windows are past h24.
143        for ev in self.events.iter().rev() {
144            let in_1h = ev.ts >= h1;
145            let in_4h = ev.ts >= h4;
146            let in_day = ev.ts >= day;
147            if !in_day {
148                // Older than 24h — cannot affect any rolling window
149                // except lifetime baseline which we compute elsewhere.
150                continue;
151            }
152
153            match &ev.kind {
154                EventKind::DecisionMade { source: _, symbol } => {
155                    if in_1h {
156                        decisions.0 += 1;
157                    }
158                    if in_4h {
159                        decisions.1 += 1;
160                    }
161                    if in_day {
162                        decisions.2 += 1;
163                    }
164                    // Re-entry: if a prior close on this symbol
165                    // occurred within the window.
166                    if let Some(last_loss_sym) = &self.last_loss_symbol
167                        && let Some(last_loss_at) = self.last_loss_at
168                        && last_loss_sym == symbol
169                    {
170                        let gap = ev.ts.signed_duration_since(last_loss_at);
171                        if gap <= Duration::minutes(15) && gap >= Duration::zero() {
172                            re_entry.within_15m += 1;
173                        }
174                        if gap <= Duration::minutes(30) && gap >= Duration::zero() {
175                            re_entry.within_30m += 1;
176                        }
177                        if gap <= Duration::hours(2) && gap >= Duration::zero() {
178                            re_entry.within_2h += 1;
179                        }
180                    }
181                }
182                EventKind::VerdictShown => {
183                    if verdicts_shown_50 < 50 {
184                        verdicts_shown_50 += 1;
185                        if verdicts_shown_10 < 10 {
186                            verdicts_shown_10 += 1;
187                        }
188                    }
189                }
190                EventKind::VerdictOverridden => {
191                    if verdicts_shown_50 < 50 {
192                        overrides_50 += 1;
193                        if verdicts_shown_10 < 10 {
194                            overrides_10 += 1;
195                        }
196                    }
197                }
198                EventKind::TradeClosed {
199                    outcome: Outcome::Loss,
200                    ..
201                } => {
202                    // Loss-reaction = time to next DecisionMade on
203                    // the same symbol. Computed forward-in-time; we
204                    // rely on the newest-first walk to capture the
205                    // nearest follow-on.
206                    let forward_decision =
207                        self.events
208                            .iter()
209                            .filter(|e| e.ts > ev.ts)
210                            .find_map(|e| match &e.kind {
211                                EventKind::DecisionMade { .. } => Some(e.ts),
212                                _ => None,
213                            });
214                    if let Some(next) = forward_decision {
215                        let raw = next.signed_duration_since(ev.ts).num_milliseconds().max(0);
216                        let ms = u64::try_from(raw).unwrap_or(u64::MAX);
217                        loss_reactions.push(ms);
218                        if ms < fastest_loss_reaction_ms {
219                            fastest_loss_reaction_ms = ms;
220                        }
221                    }
222                }
223                _ => {}
224            }
225        }
226
227        loss_reactions.sort_unstable();
228        let median_ms = if loss_reactions.is_empty() {
229            0
230        } else {
231            loss_reactions[loss_reactions.len() / 2]
232        };
233
234        let session_ms = self.session_started_at.map_or(0, |start| {
235            let d = now.signed_duration_since(start).num_milliseconds().max(0);
236            u64::try_from(d).unwrap_or(u64::MAX)
237        });
238
239        let since_last_break_ms = self.last_break_ended_at.map_or(session_ms, |end| {
240            let d = now.signed_duration_since(end).num_milliseconds().max(0);
241            u64::try_from(d).unwrap_or(u64::MAX)
242        });
243
244        StateVector {
245            velocity: Velocity {
246                last_1h: decisions.0,
247                last_4h: decisions.1,
248                last_24h: decisions.2,
249                baseline_1h: None,
250            },
251            deviation: Deviation {
252                overrides_last_10: overrides_10,
253                verdicts_last_10: verdicts_shown_10,
254                overrides_last_50: overrides_50,
255                verdicts_last_50: verdicts_shown_50,
256            },
257            session: Session {
258                active_duration_ms: session_ms,
259                longest_focus_ms: session_ms,
260                since_last_break_ms,
261            },
262            loss_reaction: LossReaction {
263                median_last_10_ms: median_ms,
264                fastest_session_ms: if fastest_loss_reaction_ms == u64::MAX {
265                    0
266                } else {
267                    fastest_loss_reaction_ms
268                },
269                baseline_ms: None,
270            },
271            re_entry,
272            sleep_proxy: SleepProxy {
273                hours_since_rest_ended: None,
274            },
275            on_break: self.on_break_since.is_some(),
276        }
277    }
278
279    /// Total events consumed.
280    #[must_use]
281    pub fn event_count(&self) -> usize {
282        self.events.len()
283    }
284}
285
286/// Apply the label rules from Addendum A §2.3 and §10.2.
287#[allow(clippy::cast_precision_loss)] // session durations fit comfortably in f64 mantissa
288fn label_for(v: &StateVector) -> Label {
289    let session_hours = v.session.active_duration_ms as f64 / 3_600_000.0;
290    let velocity_ratio = v.velocity.ratio_to_baseline();
291    let deviation = v.deviation.rate_last_10();
292
293    // TILT (§10.2): any 2 of 4 composite triggers.
294    let tilt_triggers = [
295        velocity_ratio.is_some_and(|r| r > 2.0),
296        v.loss_reaction.fastest_session_ms > 0
297            && v.loss_reaction.fastest_session_ms < 5 * 60 * 1000,
298        deviation > 0.4,
299        v.re_entry.within_15m > 0,
300    ];
301    if tilt_triggers.iter().filter(|t| **t).count() >= 2 {
302        return Label::Tilt;
303    }
304
305    // FATIGUED: session >6h continuous OR sleep proxy >18h.
306    if session_hours >= 6.0 || v.sleep_proxy.hours_since_rest_ended.is_some_and(|h| h > 18) {
307        return Label::Fatigued;
308    }
309
310    // ELEVATED: velocity 1.5x baseline OR deviation 20-40% OR session 4h+.
311    if velocity_ratio.is_some_and(|r| r >= 1.5) || deviation >= 0.2 || session_hours >= 4.0 {
312        return Label::Elevated;
313    }
314
315    // FRESH: <5 decisions in last hour AND session <2h.
316    if v.velocity.last_1h < 5 && session_hours < 2.0 {
317        return Label::Fresh;
318    }
319
320    Label::Steady
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::events::Source;
327    use chrono::TimeZone;
328
329    fn ts(min: i64) -> DateTime<Utc> {
330        chrono::TimeZone::timestamp_opt(&Utc, 1_700_000_000 + min * 60, 0).unwrap()
331    }
332
333    #[test]
334    fn empty_classifier_is_fresh() {
335        let c = Classifier::new();
336        let snap = c.classify(ts(0));
337        assert_eq!(snap.label, Label::Fresh);
338        assert_eq!(snap.vector.velocity.last_1h, 0);
339    }
340
341    #[test]
342    fn session_start_advances_duration() {
343        let mut c = Classifier::new();
344        c.push(Event::new(ts(0), EventKind::SessionStarted));
345        let snap = c.classify(ts(150)); // 2h30m
346        assert!(snap.vector.session.active_duration_ms >= 2 * 3_600_000);
347    }
348
349    #[test]
350    fn elevated_on_long_session() {
351        let mut c = Classifier::new();
352        c.push(Event::new(ts(0), EventKind::SessionStarted));
353        // 4h1m later — should be elevated solely by duration.
354        let snap = c.classify(ts(4 * 60 + 1));
355        assert_eq!(snap.label, Label::Elevated);
356    }
357
358    #[test]
359    fn fatigued_on_six_hour_session() {
360        let mut c = Classifier::new();
361        c.push(Event::new(ts(0), EventKind::SessionStarted));
362        let snap = c.classify(ts(6 * 60 + 5));
363        assert_eq!(snap.label, Label::Fatigued);
364    }
365
366    #[test]
367    fn tilt_on_reentry_plus_high_deviation() {
368        let mut c = Classifier::new();
369        c.push(Event::new(ts(0), EventKind::SessionStarted));
370        // A loss, then rapid re-entry within 15 min — trigger 1.
371        c.push(Event::new(
372            ts(10),
373            EventKind::TradeClosed {
374                symbol: "BTC".into(),
375                outcome: Outcome::Loss,
376                pnl_r: -1.0,
377                conviction: None,
378            },
379        ));
380        c.push(Event::new(
381            ts(15),
382            EventKind::DecisionMade {
383                symbol: "BTC".into(),
384                source: Source::Override,
385            },
386        ));
387        // Plan-verdict overrides to push deviation > 40% — trigger 3.
388        for m in 16..26 {
389            c.push(Event::new(ts(m), EventKind::VerdictShown));
390        }
391        for m in 16..22 {
392            c.push(Event::new(ts(m), EventKind::VerdictOverridden));
393        }
394        let snap = c.classify(ts(30));
395        assert_eq!(snap.label, Label::Tilt, "vector: {:?}", snap.vector);
396        assert_eq!(snap.friction, crate::FrictionLevel::L2);
397    }
398
399    #[test]
400    fn classify_with_risk_escalates_tilt_on_halt() {
401        use crate::events::Source;
402        use crate::friction::{FrictionLevel, RiskContext};
403
404        // Build a TILT log the same way `tilt_on_reentry_plus_high_deviation`
405        // does, then reclassify with a halt flag set.
406        let mut c = Classifier::new();
407        c.push(Event::new(ts(0), EventKind::SessionStarted));
408        c.push(Event::new(
409            ts(10),
410            EventKind::TradeClosed {
411                symbol: "BTC".into(),
412                outcome: Outcome::Loss,
413                pnl_r: -1.0,
414                conviction: None,
415            },
416        ));
417        c.push(Event::new(
418            ts(15),
419            EventKind::DecisionMade {
420                symbol: "BTC".into(),
421                source: Source::Override,
422            },
423        ));
424        for m in 16..26 {
425            c.push(Event::new(ts(m), EventKind::VerdictShown));
426        }
427        for m in 16..22 {
428            c.push(Event::new(ts(m), EventKind::VerdictOverridden));
429        }
430
431        let snap_plain = c.classify(ts(30));
432        assert_eq!(snap_plain.friction, FrictionLevel::L2);
433
434        let snap_halt = c.classify_with_risk(
435            ts(30),
436            RiskContext {
437                guardrail_proximity_pct: None,
438                halted: true,
439            },
440        );
441        assert_eq!(snap_halt.label, Label::Tilt);
442        assert_eq!(snap_halt.friction, FrictionLevel::L4);
443
444        let snap_proximity = c.classify_with_risk(
445            ts(30),
446            RiskContext {
447                guardrail_proximity_pct: Some(0.5),
448                halted: false,
449            },
450        );
451        assert_eq!(snap_proximity.friction, FrictionLevel::L3);
452    }
453
454    #[test]
455    fn version_monotonic() {
456        let mut c = Classifier::new();
457        let v0 = c.classify(ts(0)).version;
458        c.push(Event::new(ts(1), EventKind::VerdictShown));
459        let v1 = c.classify(ts(1)).version;
460        assert!(v1 > v0);
461    }
462
463    /// Determinism contract (M1_PLAN §7a line 117): feeding the
464    /// same event log to a fresh classifier at the same `now`
465    /// must produce a byte-for-byte identical `Snapshot`. This
466    /// is the property that makes replay meaningful and the
467    /// per-keypress status-bar re-render safe — a classifier
468    /// that drifted between ticks would make `ops:<LABEL>`
469    /// flicker while the operator typed. We verify by running
470    /// two disjoint classifiers over a shuffled-then-sorted
471    /// event mix and comparing the whole snapshot, not just the
472    /// label. `vector` + `version` + `friction` + `label` all
473    /// have to match — differing on any one would indicate a
474    /// hidden piece of mutable state.
475    #[test]
476    fn classify_is_deterministic_over_the_same_log() {
477        use crate::events::Source;
478
479        let now = ts(500);
480        let mix: Vec<Event> = vec![
481            Event::new(ts(0), EventKind::SessionStarted),
482            Event::new(
483                ts(60),
484                EventKind::DecisionMade {
485                    symbol: "BTC".into(),
486                    source: Source::Plan,
487                },
488            ),
489            Event::new(ts(90), EventKind::VerdictShown),
490            Event::new(
491                ts(120),
492                EventKind::TradeClosed {
493                    symbol: "BTC".into(),
494                    outcome: Outcome::Loss,
495                    pnl_r: -0.75,
496                    conviction: None,
497                },
498            ),
499            Event::new(
500                ts(121),
501                EventKind::Conviction {
502                    trade_id: "t-001".into(),
503                    rating: 7,
504                },
505            ),
506            Event::new(
507                ts(130),
508                EventKind::DecisionMade {
509                    symbol: "ETH".into(),
510                    source: Source::Override,
511                },
512            ),
513            Event::new(ts(131), EventKind::VerdictOverridden),
514            Event::new(
515                ts(200),
516                EventKind::BreakStarted {
517                    planned_ms: Some(600_000),
518                },
519            ),
520            Event::new(ts(210), EventKind::BreakEnded),
521        ];
522
523        let mut a = Classifier::new();
524        for ev in &mix {
525            a.push(ev.clone());
526        }
527
528        let mut b = Classifier::new();
529        for ev in &mix {
530            b.push(ev.clone());
531        }
532
533        let snap_a = a.classify(now);
534        let snap_b = b.classify(now);
535
536        assert_eq!(snap_a.label, snap_b.label);
537        assert_eq!(snap_a.friction, snap_b.friction);
538        assert_eq!(snap_a.version, snap_b.version);
539        assert_eq!(snap_a.vector, snap_b.vector);
540        // Reclassifying at the same `now` on the same classifier
541        // must also be idempotent — tests would otherwise green
542        // on "same input ⇒ same output" while the second call
543        // on the same classifier drifted (e.g. via interior
544        // mutability in a future refactor).
545        let snap_a2 = a.classify(now);
546        assert_eq!(snap_a.vector, snap_a2.vector);
547        assert_eq!(snap_a.version, snap_a2.version);
548    }
549
550    /// CI tripwire for Addendum A §2 / M1_PLAN §9:
551    /// "operator-state classifier tick ≤ 1 ms p95."
552    ///
553    /// Criterion (see `benches/classifier_tick.rs`) is the
554    /// detailed instrument; this test is the regression alarm
555    /// that actually fails the build if the classifier gets
556    /// slow. We measure only the typical-load case (512 events,
557    /// a full day of activity) with an inline wall-clock loop
558    /// — criterion benches do not fail CI on their own.
559    ///
560    /// The ceiling is 500 µs (half the spec budget), not 1 ms:
561    /// the spec says p95, we measure mean. Mean should be well
562    /// under p95 in a stable distribution, and cutting the
563    /// budget in half here gives us headroom for debug vs
564    /// release variance, loaded runners, and the fact that a
565    /// cold-branch first iteration tends to be an outlier.
566    /// If this trips, a real regression is very likely —
567    /// tune the heuristic, not the budget.
568    ///
569    /// Runs under `--release` only. In debug, the classifier's
570    /// rolling-window arithmetic is 10–50× slower; asserting
571    /// a 500 µs budget there would be spurious failure bait,
572    /// so we skip. Anyone wanting to confirm debug behavior
573    /// can run `cargo bench` instead, which always compiles
574    /// the bench target in release mode by construction.
575    #[test]
576    #[cfg_attr(debug_assertions, ignore = "release-only perf tripwire")]
577    #[allow(clippy::too_many_lines)]
578    fn classifier_tick_under_budget_on_typical_load() {
579        use std::time::Instant;
580
581        use crate::friction::RiskContext;
582
583        // 500 µs: half the 1 ms spec budget (see fn docs).
584        const BUDGET_US: u128 = 500;
585        const ITERATIONS: u32 = 1_000;
586
587        let now = Utc.with_ymd_and_hms(2026, 4, 21, 18, 0, 0).unwrap();
588        // Mirror the bench's "typical" load point. Kept inline
589        // rather than shared because dev-dep imports from a
590        // `benches/` target don't flow back into `src/`.
591        let mut c = Classifier::new();
592        c.push(Event::new(
593            now - chrono::Duration::hours(6),
594            EventKind::SessionStarted,
595        ));
596        for i in 0..512u32 {
597            let ts_i = now - chrono::Duration::milliseconds(i64::from(i) * 1_000);
598            let symbol = ["BTC", "ETH", "SOL", "AVAX"][(i as usize) % 4].to_string();
599            let kind = match i % 20 {
600                0..=11 => EventKind::DecisionMade {
601                    symbol,
602                    source: Source::Manual,
603                },
604                12 => EventKind::TradeClosed {
605                    symbol,
606                    outcome: Outcome::Win,
607                    pnl_r: 1.2,
608                    conviction: Some(7),
609                },
610                13 => EventKind::TradeClosed {
611                    symbol,
612                    outcome: Outcome::Loss,
613                    pnl_r: -0.8,
614                    conviction: Some(5),
615                },
616                14 => EventKind::TradeClosed {
617                    symbol,
618                    outcome: Outcome::Scratch,
619                    pnl_r: 0.0,
620                    conviction: Some(6),
621                },
622                15 => EventKind::VerdictShown,
623                16 => EventKind::VerdictOverridden,
624                17 => EventKind::Idle { since_ms: 30_000 },
625                18 => EventKind::Resumed,
626                _ => EventKind::BreakStarted {
627                    planned_ms: Some(300_000),
628                },
629            };
630            c.push(Event::new(ts_i, kind));
631        }
632
633        // Warm up once to let the allocator steady. Discarding
634        // the first call is standard perf-test hygiene and
635        // prevents a cold-cache first sample from dominating
636        // the mean.
637        let _ = c.classify(now);
638
639        let start = Instant::now();
640        for _ in 0..ITERATIONS {
641            let snap = c.classify(now);
642            // Use the result so the optimizer cannot elide the
643            // call. `snap.version` is the cheapest non-trivial
644            // read on the snapshot.
645            std::hint::black_box(snap.version);
646        }
647        let elapsed = start.elapsed();
648        let per_call = elapsed / ITERATIONS;
649
650        assert!(
651            per_call.as_micros() < BUDGET_US,
652            "classifier tick mean {per_call:?} exceeded {BUDGET_US}µs budget \
653             (spec p95 ≤ 1 ms). Run `cargo bench -p zero-operator-state` \
654             for the full distribution."
655        );
656
657        // M2 §3 approaching-halt load point (1 024 events)
658        // exercises `classify_with_risk` so the L3 escalation
659        // branch is covered by the same budget. The
660        // `RiskContext` supplied puts drawdown within 0.5 pp of
661        // the alert, which routes through the L3 branch — the
662        // hottest new code path added by M2 §3.
663        let mut c_halt = Classifier::new();
664        c_halt.push(Event::new(
665            now - chrono::Duration::hours(6),
666            EventKind::SessionStarted,
667        ));
668        for i in 0..1_024u32 {
669            let ts_i = now - chrono::Duration::milliseconds(i64::from(i) * 500);
670            let symbol = ["BTC", "ETH", "SOL", "AVAX"][(i as usize) % 4].to_string();
671            let kind = match i % 20 {
672                0..=11 => EventKind::DecisionMade {
673                    symbol,
674                    source: Source::Manual,
675                },
676                12 => EventKind::TradeClosed {
677                    symbol,
678                    outcome: Outcome::Win,
679                    pnl_r: 1.2,
680                    conviction: Some(7),
681                },
682                13 => EventKind::TradeClosed {
683                    symbol,
684                    outcome: Outcome::Loss,
685                    pnl_r: -0.8,
686                    conviction: Some(5),
687                },
688                14 => EventKind::TradeClosed {
689                    symbol,
690                    outcome: Outcome::Scratch,
691                    pnl_r: 0.0,
692                    conviction: Some(6),
693                },
694                15 => EventKind::VerdictShown,
695                16 => EventKind::VerdictOverridden,
696                17 => EventKind::Idle { since_ms: 30_000 },
697                18 => EventKind::Resumed,
698                _ => EventKind::BreakStarted {
699                    planned_ms: Some(300_000),
700                },
701            };
702            c_halt.push(Event::new(ts_i, kind));
703        }
704        let risk = RiskContext {
705            guardrail_proximity_pct: Some(0.5),
706            halted: false,
707        };
708        let _ = c_halt.classify_with_risk(now, risk);
709
710        let start = Instant::now();
711        for _ in 0..ITERATIONS {
712            let snap = c_halt.classify_with_risk(now, risk);
713            std::hint::black_box(snap.version);
714        }
715        let per_call = start.elapsed() / ITERATIONS;
716        assert!(
717            per_call.as_micros() < BUDGET_US,
718            "classify_with_risk (1024-event approaching-halt mix) mean {per_call:?} \
719             exceeded {BUDGET_US}µs budget. The M2 §3 escalation branches must not \
720             degrade the tick budget — see M2_PLAN §3."
721        );
722    }
723}