1use 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#[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 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 _ => {}
85 }
86 self.events.push(event);
87 self.version = self.version.wrapping_add(1);
88 }
89
90 #[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 #[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)] 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 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 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 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 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 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 #[must_use]
281 pub fn event_count(&self) -> usize {
282 self.events.len()
283 }
284}
285
286#[allow(clippy::cast_precision_loss)] fn 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 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 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 if velocity_ratio.is_some_and(|r| r >= 1.5) || deviation >= 0.2 || session_hours >= 4.0 {
312 return Label::Elevated;
313 }
314
315 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)); 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 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 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 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 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 #[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 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 #[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 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 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 let _ = c.classify(now);
638
639 let start = Instant::now();
640 for _ in 0..ITERATIONS {
641 let snap = c.classify(now);
642 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 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}