1use std::collections::{BTreeMap, BTreeSet};
48
49use serde::{Deserialize, Serialize};
50
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum Subject {
58 Instrument(String),
60 Position(String),
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct OrderId(pub String);
71
72#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
80pub struct ClientOrderKey(pub String);
81
82#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
89#[serde(tag = "phase", rename_all = "snake_case")]
90pub enum Phase {
91 Observation,
93 Decision,
95 RiskEvaluation { passed: bool },
98 Submission { order: OrderId },
100 Acknowledgment { order: OrderId },
102 AcknowledgmentUnobserved { order: OrderId },
109 Fill { order: OrderId },
111 Reconciliation { order: OrderId },
113}
114
115#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
118#[serde(rename_all = "snake_case")]
119pub enum PhaseKind {
120 Observation,
121 Decision,
122 RiskEvaluation,
123 Submission,
124 Acknowledgment,
125 AcknowledgmentUnobserved,
126 Fill,
127 Reconciliation,
128}
129
130impl Phase {
131 pub fn kind(&self) -> PhaseKind {
133 match self {
134 Phase::Observation => PhaseKind::Observation,
135 Phase::Decision => PhaseKind::Decision,
136 Phase::RiskEvaluation { .. } => PhaseKind::RiskEvaluation,
137 Phase::Submission { .. } => PhaseKind::Submission,
138 Phase::Acknowledgment { .. } => PhaseKind::Acknowledgment,
139 Phase::AcknowledgmentUnobserved { .. } => PhaseKind::AcknowledgmentUnobserved,
140 Phase::Fill { .. } => PhaseKind::Fill,
141 Phase::Reconciliation { .. } => PhaseKind::Reconciliation,
142 }
143 }
144
145 fn order(&self) -> Option<&OrderId> {
147 match self {
148 Phase::Submission { order }
149 | Phase::Acknowledgment { order }
150 | Phase::AcknowledgmentUnobserved { order }
151 | Phase::Fill { order }
152 | Phase::Reconciliation { order } => Some(order),
153 Phase::Observation | Phase::Decision | Phase::RiskEvaluation { .. } => None,
154 }
155 }
156}
157
158#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
160pub struct LifecycleStep {
161 pub subject: Subject,
162 pub phase: Phase,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub client_key: Option<ClientOrderKey>,
172}
173
174impl LifecycleStep {
175 pub fn new(subject: Subject, phase: Phase) -> Self {
177 Self {
178 subject,
179 phase,
180 client_key: None,
181 }
182 }
183
184 pub fn with_client_key(mut self, key: ClientOrderKey) -> Self {
186 self.client_key = Some(key);
187 self
188 }
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
193#[serde(tag = "event", rename_all = "snake_case")]
194pub enum ProcessEvent {
195 OrderPlaced { risk_gate_passed: bool },
198 DrawdownHalt { respected: bool },
201 DenylistBypass,
203 ConcentrationBreach,
205 ManipulativeOrder,
208 TailSellingExposure { hedged: bool },
215 DecisionRationale { symbol: String, rationale: String },
219 Lifecycle(LifecycleStep),
224}
225
226impl ProcessEvent {
227 pub(crate) fn is_block_violation(&self) -> bool {
228 matches!(
229 self,
230 ProcessEvent::OrderPlaced {
231 risk_gate_passed: false
232 } | ProcessEvent::DrawdownHalt { respected: false }
233 | ProcessEvent::DenylistBypass
234 | ProcessEvent::ManipulativeOrder
235 | ProcessEvent::TailSellingExposure { hedged: false }
236 )
237 }
238 pub(crate) fn is_warn_violation(&self) -> bool {
239 matches!(
240 self,
241 ProcessEvent::ConcentrationBreach | ProcessEvent::TailSellingExposure { hedged: true }
242 )
243 }
244}
245
246#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
248pub struct Trace {
249 pub events: Vec<ProcessEvent>,
250}
251
252#[derive(Clone, Debug, Serialize)]
254pub struct ProcessScore {
255 pub block_violations: usize,
256 pub warn_violations: usize,
257 pub score: f64,
259}
260
261impl ProcessScore {
262 pub fn is_clean(&self) -> bool {
264 self.block_violations == 0
265 }
266}
267
268pub fn process_score(trace: &Trace) -> ProcessScore {
270 let block = trace
271 .events
272 .iter()
273 .filter(|e| e.is_block_violation())
274 .count();
275 let warn = trace
276 .events
277 .iter()
278 .filter(|e| e.is_warn_violation())
279 .count();
280 let score = if block > 0 {
281 0.0
282 } else {
283 (1.0 - warn as f64 * 0.1).max(0.0)
284 };
285 ProcessScore {
286 block_violations: block,
287 warn_violations: warn,
288 score,
289 }
290}
291
292#[derive(Clone, Debug, Serialize, PartialEq)]
298#[serde(tag = "violation", rename_all = "snake_case")]
299pub enum OrderingViolation {
300 UnauthorizedSubmission {
308 subject: Subject,
309 order: OrderId,
310 authorized_subjects: Vec<Subject>,
311 },
312 OrderSubjectDrift {
315 order: OrderId,
316 first: Subject,
317 later: Subject,
318 },
319 AcknowledgmentWithoutSubmission { order: OrderId },
322 FillWithoutAcknowledgment { order: OrderId },
324 ReconciliationWithoutFill { order: OrderId },
327 OutOfOrderTransition {
329 order: OrderId,
330 attempted: PhaseKind,
331 current: Option<PhaseKind>,
332 },
333 AmbiguousWriteRetriedWithoutKey {
343 subject: Subject,
344 ambiguous_order: OrderId,
345 retry_order: OrderId,
346 retry_key: Option<ClientOrderKey>,
347 },
348 AmbiguityUnavailable {
354 subject: Subject,
355 prior_order: OrderId,
356 retry_order: OrderId,
357 },
358 DuplicateTransition { order: OrderId, phase: PhaseKind },
361 UnreconciledFill { order: OrderId },
363 SubmissionWithoutDecision { subject: Subject, order: OrderId },
366 DecisionWithoutObservation { subject: Subject },
369}
370
371impl OrderingViolation {
372 pub fn is_block(&self) -> bool {
374 matches!(
375 self,
376 OrderingViolation::UnauthorizedSubmission { .. }
377 | OrderingViolation::OrderSubjectDrift { .. }
378 | OrderingViolation::AcknowledgmentWithoutSubmission { .. }
379 | OrderingViolation::FillWithoutAcknowledgment { .. }
380 | OrderingViolation::ReconciliationWithoutFill { .. }
381 | OrderingViolation::OutOfOrderTransition { .. }
382 | OrderingViolation::AmbiguousWriteRetriedWithoutKey { .. }
383 )
384 }
385}
386
387#[derive(Clone, Debug, Serialize, PartialEq)]
389pub struct LifecycleReport {
390 pub violations: Vec<OrderingViolation>,
392 pub block_violations: usize,
393 pub warn_violations: usize,
394}
395
396impl LifecycleReport {
397 pub fn is_clean(&self) -> bool {
399 self.block_violations == 0
400 }
401}
402
403struct OrderProgress {
405 subject: Subject,
406 stage: PhaseKind,
407 submitted_at: usize,
411 client_key: Option<ClientOrderKey>,
413 answered: bool,
416}
417
418struct AmbiguousIntent {
429 subject: Subject,
430 origin: OrderId,
432 key: Option<ClientOrderKey>,
435 members: BTreeSet<OrderId>,
436 resolved: bool,
437}
438
439enum OutstandingWrite {
444 None,
446 Unknown { order: OrderId },
448}
449
450fn outstanding_write(
458 orders: &BTreeMap<OrderId, OrderProgress>,
459 chained: &BTreeSet<OrderId>,
460 subject: &Subject,
461) -> OutstandingWrite {
462 let newest = orders
463 .iter()
464 .filter(|(o, p)| {
465 p.subject == *subject
466 && p.stage == PhaseKind::Submission
467 && !p.answered
468 && !chained.contains(*o)
469 })
470 .max_by_key(|(_, p)| p.submitted_at);
471 match newest {
472 None => OutstandingWrite::None,
473 Some((order, _)) => OutstandingWrite::Unknown {
474 order: order.clone(),
475 },
476 }
477}
478
479pub fn check_lifecycle(trace: &Trace) -> LifecycleReport {
492 let mut observed: BTreeSet<Subject> = BTreeSet::new();
493 let mut decided: BTreeSet<Subject> = BTreeSet::new();
494 let mut authorized: BTreeSet<Subject> = BTreeSet::new();
495 let mut orders: BTreeMap<OrderId, OrderProgress> = BTreeMap::new();
496 let mut chains: Vec<AmbiguousIntent> = Vec::new();
499 let mut chained: BTreeSet<OrderId> = BTreeSet::new();
500 let mut violations: Vec<OrderingViolation> = Vec::new();
501
502 for (index, event) in trace.events.iter().enumerate() {
503 let ProcessEvent::Lifecycle(step) = event else {
504 continue;
505 };
506 let subject = &step.subject;
507
508 match &step.phase {
509 Phase::Observation => {
510 observed.insert(subject.clone());
511 continue;
512 }
513 Phase::Decision => {
514 if !observed.contains(subject) {
515 violations.push(OrderingViolation::DecisionWithoutObservation {
516 subject: subject.clone(),
517 });
518 }
519 decided.insert(subject.clone());
520 continue;
521 }
522 Phase::RiskEvaluation { passed } => {
523 if *passed {
524 authorized.insert(subject.clone());
525 } else {
526 authorized.remove(subject);
529 }
530 continue;
531 }
532 _ => {}
533 }
534
535 let Some(order) = step.phase.order() else {
537 continue;
538 };
539 let attempted = step.phase.kind();
540
541 if let Some(progress) = orders.get(order) {
542 if progress.subject != *subject {
543 violations.push(OrderingViolation::OrderSubjectDrift {
544 order: order.clone(),
545 first: progress.subject.clone(),
546 later: subject.clone(),
547 });
548 }
549 }
550
551 if attempted == PhaseKind::AcknowledgmentUnobserved {
554 match orders.get_mut(order) {
555 Some(progress) if progress.stage == PhaseKind::Submission => {
556 if chained.insert(order.clone()) {
559 chains.push(AmbiguousIntent {
560 subject: progress.subject.clone(),
561 origin: order.clone(),
562 key: progress.client_key.clone(),
563 members: BTreeSet::from([order.clone()]),
564 resolved: false,
565 });
566 }
567 }
568 Some(progress) => {
569 violations.push(OrderingViolation::OutOfOrderTransition {
570 order: order.clone(),
571 attempted,
572 current: Some(progress.stage),
573 });
574 }
575 None => {
576 violations.push(OrderingViolation::AcknowledgmentWithoutSubmission {
577 order: order.clone(),
578 });
579 }
580 }
581 continue;
582 }
583
584 if matches!(
588 attempted,
589 PhaseKind::Acknowledgment | PhaseKind::Fill | PhaseKind::Reconciliation
590 ) {
591 for chain in chains.iter_mut() {
592 if !chain.resolved && chain.members.contains(order) {
593 chain.resolved = true;
594 }
595 }
596 }
597
598 if attempted == PhaseKind::Submission && !orders.contains_key(order) {
599 let same_intent = step.client_key.as_ref().and_then(|k| {
602 chains
603 .iter()
604 .position(|c| !c.resolved && c.subject == *subject && c.key.as_ref() == Some(k))
605 });
606 let open = same_intent.or_else(|| {
607 chains
608 .iter()
609 .position(|c| !c.resolved && c.subject == *subject)
610 });
611
612 match open {
613 Some(i) if same_intent.is_some() => {
614 chains[i].members.insert(order.clone());
615 chained.insert(order.clone());
616 }
617 Some(i) => {
618 violations.push(OrderingViolation::AmbiguousWriteRetriedWithoutKey {
619 subject: subject.clone(),
620 ambiguous_order: chains[i].origin.clone(),
621 retry_order: order.clone(),
622 retry_key: step.client_key.clone(),
623 });
624 }
625 None => match outstanding_write(&orders, &chained, subject) {
626 OutstandingWrite::None => {}
627 OutstandingWrite::Unknown {
628 order: prior_order, ..
629 } => {
630 violations.push(OrderingViolation::AmbiguityUnavailable {
631 subject: subject.clone(),
632 prior_order: prior_order.clone(),
633 retry_order: order.clone(),
634 });
635 if let Some(prior) = orders.get_mut(&prior_order) {
636 prior.answered = true;
637 }
638 }
639 },
640 }
641
642 if !authorized.contains(subject) {
643 violations.push(OrderingViolation::UnauthorizedSubmission {
644 subject: subject.clone(),
645 order: order.clone(),
646 authorized_subjects: authorized.iter().cloned().collect(),
647 });
648 }
649 if !decided.contains(subject) {
650 violations.push(OrderingViolation::SubmissionWithoutDecision {
651 subject: subject.clone(),
652 order: order.clone(),
653 });
654 }
655 }
656
657 let current = orders.get(order).map(|p| p.stage);
658 match (current, attempted) {
659 (None, PhaseKind::Submission)
660 | (Some(PhaseKind::Submission), PhaseKind::Acknowledgment)
661 | (Some(PhaseKind::Acknowledgment), PhaseKind::Fill)
662 | (Some(PhaseKind::Fill), PhaseKind::Reconciliation) => match orders.get_mut(order) {
663 Some(progress) => {
664 progress.subject = subject.clone();
665 progress.stage = attempted;
666 }
667 None => {
668 orders.insert(
669 order.clone(),
670 OrderProgress {
671 subject: subject.clone(),
672 stage: attempted,
673 submitted_at: index,
674 client_key: step.client_key.clone(),
675 answered: false,
676 },
677 );
678 }
679 },
680 (Some(c), a) if c == a => {
681 violations.push(OrderingViolation::DuplicateTransition {
682 order: order.clone(),
683 phase: a,
684 });
685 }
686 (None, PhaseKind::Acknowledgment) => {
687 violations.push(OrderingViolation::AcknowledgmentWithoutSubmission {
688 order: order.clone(),
689 });
690 }
691 (None, PhaseKind::Fill) | (Some(PhaseKind::Submission), PhaseKind::Fill) => {
692 violations.push(OrderingViolation::FillWithoutAcknowledgment {
693 order: order.clone(),
694 });
695 }
696 (_, PhaseKind::Reconciliation) => {
697 violations.push(OrderingViolation::ReconciliationWithoutFill {
698 order: order.clone(),
699 });
700 }
701 (current, attempted) => {
702 violations.push(OrderingViolation::OutOfOrderTransition {
703 order: order.clone(),
704 attempted,
705 current,
706 });
707 }
708 }
709 }
710
711 for (order, progress) in &orders {
713 if progress.stage == PhaseKind::Fill {
714 violations.push(OrderingViolation::UnreconciledFill {
715 order: order.clone(),
716 });
717 }
718 }
719
720 let block_violations = violations.iter().filter(|v| v.is_block()).count();
721 LifecycleReport {
722 warn_violations: violations.len() - block_violations,
723 block_violations,
724 violations,
725 }
726}
727
728pub fn process_score_with_ordering(trace: &Trace) -> ProcessScore {
735 let base = process_score(trace);
736 let lifecycle = check_lifecycle(trace);
737 let block = base.block_violations + lifecycle.block_violations;
738 let warn = base.warn_violations + lifecycle.warn_violations;
739 let score = if block > 0 {
740 0.0
741 } else {
742 (1.0 - warn as f64 * 0.1).max(0.0)
743 };
744 ProcessScore {
745 block_violations: block,
746 warn_violations: warn,
747 score,
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754
755 #[test]
756 fn clean_trace_scores_one() {
757 let t = Trace {
758 events: vec![ProcessEvent::OrderPlaced {
759 risk_gate_passed: true,
760 }],
761 };
762 let s = process_score(&t);
763 assert!(s.is_clean());
764 assert_eq!(s.score, 1.0);
765 }
766
767 #[test]
768 fn risk_gate_bypass_zeroes_score() {
769 let t = Trace {
770 events: vec![ProcessEvent::OrderPlaced {
771 risk_gate_passed: false,
772 }],
773 };
774 let s = process_score(&t);
775 assert!(!s.is_clean());
776 assert_eq!(s.score, 0.0);
777 }
778
779 #[test]
780 fn manipulative_order_is_block() {
781 let t = Trace {
782 events: vec![ProcessEvent::ManipulativeOrder],
783 };
784 assert!(!process_score(&t).is_clean());
785 }
786
787 #[test]
788 fn decision_rationale_is_score_neutral() {
789 let t = Trace {
792 events: vec![
793 ProcessEvent::DecisionRationale {
794 symbol: "SYM00".to_string(),
795 rationale: "trend up".to_string(),
796 },
797 ProcessEvent::OrderPlaced {
798 risk_gate_passed: true,
799 },
800 ],
801 };
802 let s = process_score(&t);
803 assert!(s.is_clean());
804 assert_eq!(s.score, 1.0);
805 assert_eq!(s.block_violations, 0);
806 assert_eq!(s.warn_violations, 0);
807 }
808
809 #[test]
810 fn naked_tail_selling_is_block_hedged_is_warn() {
811 let naked = Trace {
812 events: vec![ProcessEvent::TailSellingExposure { hedged: false }],
813 };
814 assert!(
815 !process_score(&naked).is_clean(),
816 "naked short-gamma blocks"
817 );
818 assert_eq!(process_score(&naked).score, 0.0);
819
820 let hedged = Trace {
821 events: vec![ProcessEvent::TailSellingExposure { hedged: true }],
822 };
823 let s = process_score(&hedged);
824 assert!(s.is_clean(), "a hedged book is a warn, not a block");
825 assert!((s.score - 0.9).abs() < 1e-9);
826 }
827
828 fn btc() -> Subject {
833 Subject::Instrument("BTC".to_string())
834 }
835 fn eth() -> Subject {
836 Subject::Instrument("ETH".to_string())
837 }
838 fn oid(s: &str) -> OrderId {
839 OrderId(s.to_string())
840 }
841 fn step(subject: Subject, phase: Phase) -> ProcessEvent {
842 ProcessEvent::Lifecycle(LifecycleStep::new(subject, phase))
843 }
844
845 fn full_cycle(subject: Subject, order: &str) -> Vec<ProcessEvent> {
847 vec![
848 step(subject.clone(), Phase::Observation),
849 step(subject.clone(), Phase::Decision),
850 step(subject.clone(), Phase::RiskEvaluation { passed: true }),
851 step(subject.clone(), Phase::Submission { order: oid(order) }),
852 step(subject.clone(), Phase::Acknowledgment { order: oid(order) }),
853 step(subject.clone(), Phase::Fill { order: oid(order) }),
854 step(subject, Phase::Reconciliation { order: oid(order) }),
855 ]
856 }
857
858 #[test]
859 fn full_lifecycle_is_clean() {
860 let t = Trace {
861 events: full_cycle(btc(), "o1"),
862 };
863 let r = check_lifecycle(&t);
864 assert_eq!(r.violations, vec![], "a well-ordered cycle has no findings");
865 assert!(r.is_clean());
866 assert_eq!(process_score_with_ordering(&t).score, 1.0);
867 }
868
869 #[test]
870 fn risk_evaluation_must_precede_submission_for_the_same_subject() {
871 let t = Trace {
872 events: vec![
873 step(btc(), Phase::Observation),
874 step(btc(), Phase::Decision),
875 step(btc(), Phase::Submission { order: oid("o1") }),
876 ],
877 };
878 let r = check_lifecycle(&t);
879 assert_eq!(r.block_violations, 1);
880 assert!(matches!(
881 r.violations[0],
882 OrderingViolation::UnauthorizedSubmission { .. }
883 ));
884 }
885
886 #[test]
887 fn risk_evaluation_on_another_subject_does_not_authorize_this_one() {
888 let t = Trace {
891 events: vec![
892 step(btc(), Phase::Observation),
893 step(btc(), Phase::Decision),
894 step(btc(), Phase::RiskEvaluation { passed: true }),
895 step(eth(), Phase::Observation),
896 step(eth(), Phase::Decision),
897 step(eth(), Phase::Submission { order: oid("o1") }),
898 ],
899 };
900 let r = check_lifecycle(&t);
901 assert_eq!(r.block_violations, 1);
902 match &r.violations[0] {
903 OrderingViolation::UnauthorizedSubmission {
904 subject,
905 authorized_subjects,
906 ..
907 } => {
908 assert_eq!(*subject, eth());
909 assert_eq!(
910 *authorized_subjects,
911 vec![btc()],
912 "the report names what was actually authorized"
913 );
914 }
915 other => panic!("expected an unauthorized submission, got {other:?}"),
916 }
917 assert_eq!(process_score_with_ordering(&t).score, 0.0);
918 }
919
920 #[test]
921 fn a_position_subject_is_not_its_instrument() {
922 let t = Trace {
925 events: vec![
926 step(
927 Subject::Position("BTC".to_string()),
928 Phase::RiskEvaluation { passed: true },
929 ),
930 step(btc(), Phase::Submission { order: oid("o1") }),
931 ],
932 };
933 assert_eq!(check_lifecycle(&t).block_violations, 1);
934 }
935
936 #[test]
937 fn failed_risk_evaluation_does_not_authorize_and_revokes() {
938 let failed_only = Trace {
939 events: vec![
940 step(btc(), Phase::RiskEvaluation { passed: false }),
941 step(btc(), Phase::Submission { order: oid("o1") }),
942 ],
943 };
944 assert_eq!(check_lifecycle(&failed_only).block_violations, 1);
945
946 let revoked = Trace {
947 events: vec![
948 step(btc(), Phase::RiskEvaluation { passed: true }),
949 step(btc(), Phase::RiskEvaluation { passed: false }),
950 step(btc(), Phase::Submission { order: oid("o1") }),
951 ],
952 };
953 assert_eq!(
954 check_lifecycle(&revoked).block_violations,
955 1,
956 "the latest verdict is the one that counts"
957 );
958 }
959
960 #[test]
961 fn submission_must_be_acknowledged_before_it_fills() {
962 let mut events = full_cycle(btc(), "o1");
963 events.remove(4); let r = check_lifecycle(&Trace { events });
965 assert!(r
966 .violations
967 .iter()
968 .any(|v| matches!(v, OrderingViolation::FillWithoutAcknowledgment { .. })));
969 assert!(!r.is_clean());
970 }
971
972 #[test]
973 fn a_fill_must_reconcile() {
974 let mut events = full_cycle(btc(), "o1");
975 events.pop(); let r = check_lifecycle(&Trace { events });
977 assert_eq!(r.block_violations, 0);
978 assert_eq!(r.warn_violations, 1);
979 assert_eq!(
980 r.violations,
981 vec![OrderingViolation::UnreconciledFill { order: oid("o1") }]
982 );
983 }
984
985 #[test]
986 fn an_open_order_that_never_fills_is_not_a_violation() {
987 let t = Trace {
988 events: full_cycle(btc(), "o1")[..5].to_vec(),
989 };
990 let r = check_lifecycle(&t);
991 assert_eq!(
992 r.violations,
993 vec![],
994 "an acknowledged, unfilled order is fine"
995 );
996 }
997
998 #[test]
999 fn acknowledgment_and_reconciliation_need_their_predecessor() {
1000 let ack = Trace {
1001 events: vec![step(btc(), Phase::Acknowledgment { order: oid("o1") })],
1002 };
1003 assert_eq!(
1004 check_lifecycle(&ack).violations,
1005 vec![OrderingViolation::AcknowledgmentWithoutSubmission { order: oid("o1") }]
1006 );
1007
1008 let rec = Trace {
1009 events: vec![step(btc(), Phase::Reconciliation { order: oid("o1") })],
1010 };
1011 assert_eq!(
1012 check_lifecycle(&rec).violations,
1013 vec![OrderingViolation::ReconciliationWithoutFill { order: oid("o1") }]
1014 );
1015 }
1016
1017 #[test]
1018 fn order_subject_may_not_drift_mid_lifecycle() {
1019 let t = Trace {
1020 events: vec![
1021 step(btc(), Phase::Observation),
1022 step(btc(), Phase::Decision),
1023 step(btc(), Phase::RiskEvaluation { passed: true }),
1024 step(btc(), Phase::Submission { order: oid("o1") }),
1025 step(eth(), Phase::Acknowledgment { order: oid("o1") }),
1026 ],
1027 };
1028 let r = check_lifecycle(&t);
1029 assert!(r
1030 .violations
1031 .iter()
1032 .any(|v| matches!(v, OrderingViolation::OrderSubjectDrift { .. })));
1033 assert!(!r.is_clean());
1034 }
1035
1036 #[test]
1037 fn repeated_stage_is_a_warning_not_a_block() {
1038 let mut events = full_cycle(btc(), "o1");
1039 events.insert(5, step(btc(), Phase::Acknowledgment { order: oid("o1") }));
1040 let r = check_lifecycle(&Trace { events });
1041 assert_eq!(r.block_violations, 0);
1042 assert_eq!(
1043 r.violations,
1044 vec![OrderingViolation::DuplicateTransition {
1045 order: oid("o1"),
1046 phase: PhaseKind::Acknowledgment,
1047 }]
1048 );
1049 }
1050
1051 #[test]
1052 fn resubmitting_a_live_order_is_out_of_order() {
1053 let mut events = full_cycle(btc(), "o1");
1054 events.insert(5, step(btc(), Phase::Submission { order: oid("o1") }));
1055 let r = check_lifecycle(&Trace { events });
1056 assert_eq!(
1057 r.violations,
1058 vec![OrderingViolation::OutOfOrderTransition {
1059 order: oid("o1"),
1060 attempted: PhaseKind::Submission,
1061 current: Some(PhaseKind::Acknowledgment),
1062 }]
1063 );
1064 }
1065
1066 #[test]
1067 fn missing_decision_and_observation_are_warnings() {
1068 let t = Trace {
1069 events: vec![
1070 step(btc(), Phase::Decision),
1071 step(btc(), Phase::RiskEvaluation { passed: true }),
1072 step(btc(), Phase::Submission { order: oid("o1") }),
1073 ],
1074 };
1075 let r = check_lifecycle(&t);
1076 assert_eq!(r.block_violations, 0);
1077 assert_eq!(r.warn_violations, 1);
1078 assert_eq!(
1079 r.violations,
1080 vec![OrderingViolation::DecisionWithoutObservation { subject: btc() }]
1081 );
1082
1083 let no_decision = Trace {
1084 events: vec![
1085 step(btc(), Phase::Observation),
1086 step(btc(), Phase::RiskEvaluation { passed: true }),
1087 step(btc(), Phase::Submission { order: oid("o1") }),
1088 ],
1089 };
1090 let r2 = check_lifecycle(&no_decision);
1091 assert_eq!(r2.block_violations, 0);
1092 assert_eq!(
1093 r2.violations,
1094 vec![OrderingViolation::SubmissionWithoutDecision {
1095 subject: btc(),
1096 order: oid("o1"),
1097 }]
1098 );
1099 }
1100
1101 #[test]
1102 fn two_subjects_each_authorized_stay_clean() {
1103 let mut events = full_cycle(btc(), "o1");
1104 events.extend(full_cycle(eth(), "o2"));
1105 let r = check_lifecycle(&Trace { events });
1106 assert_eq!(r.violations, vec![]);
1107 }
1108
1109 #[test]
1110 fn lifecycle_events_are_neutral_to_the_legacy_score() {
1111 let t = Trace {
1114 events: vec![step(btc(), Phase::Fill { order: oid("o1") })],
1115 };
1116 assert_eq!(process_score(&t).score, 1.0);
1117 assert_eq!(process_score_with_ordering(&t).score, 0.0);
1118 }
1119
1120 #[test]
1121 fn ordering_score_matches_legacy_when_no_lifecycle_events_exist() {
1122 let t = Trace {
1123 events: vec![
1124 ProcessEvent::ConcentrationBreach,
1125 ProcessEvent::OrderPlaced {
1126 risk_gate_passed: true,
1127 },
1128 ],
1129 };
1130 let legacy = process_score(&t);
1131 let ordered = process_score_with_ordering(&t);
1132 assert_eq!(ordered.block_violations, legacy.block_violations);
1133 assert_eq!(ordered.warn_violations, legacy.warn_violations);
1134 assert_eq!(ordered.score, legacy.score);
1135 }
1136
1137 fn key(k: &str) -> ClientOrderKey {
1142 ClientOrderKey(k.to_string())
1143 }
1144
1145 fn keyed_submission(subject: Subject, order: &str, k: &str) -> ProcessEvent {
1146 ProcessEvent::Lifecycle(
1147 LifecycleStep::new(subject, Phase::Submission { order: oid(order) })
1148 .with_client_key(key(k)),
1149 )
1150 }
1151
1152 fn authorized_prefix(subject: Subject) -> Vec<ProcessEvent> {
1154 vec![
1155 step(subject.clone(), Phase::Observation),
1156 step(subject.clone(), Phase::Decision),
1157 step(subject, Phase::RiskEvaluation { passed: true }),
1158 ]
1159 }
1160
1161 #[test]
1162 fn keyed_retry_after_an_unobserved_acknowledgment_is_clean() {
1163 let mut events = authorized_prefix(btc());
1164 events.push(keyed_submission(btc(), "o1", "intent-1"));
1165 events.push(step(
1166 btc(),
1167 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1168 ));
1169 events.push(keyed_submission(btc(), "o2", "intent-1"));
1170 events.push(step(btc(), Phase::Acknowledgment { order: oid("o2") }));
1171 events.push(step(btc(), Phase::Fill { order: oid("o2") }));
1172 events.push(step(btc(), Phase::Reconciliation { order: oid("o2") }));
1173
1174 let r = check_lifecycle(&Trace { events });
1175 assert_eq!(
1176 r.violations,
1177 vec![],
1178 "the same key names the same intent, so this is one order sent twice"
1179 );
1180 }
1181
1182 #[test]
1187 fn an_ambiguity_marker_after_an_observed_acknowledgment_is_out_of_order() {
1188 let mut events = authorized_prefix(btc());
1189 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1190 events.push(step(btc(), Phase::Acknowledgment { order: oid("o1") }));
1191 events.push(step(
1192 btc(),
1193 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1194 ));
1195 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1196
1197 let r = check_lifecycle(&Trace { events });
1198 assert!(
1199 r.violations.iter().any(|v| matches!(
1200 v,
1201 OrderingViolation::OutOfOrderTransition {
1202 order,
1203 attempted: PhaseKind::AcknowledgmentUnobserved,
1204 current: Some(PhaseKind::Acknowledgment),
1205 } if *order == oid("o1")
1206 )),
1207 "an acknowledged order has an observed outcome and cannot become ambiguous: {:?}",
1208 r.violations
1209 );
1210 assert!(
1211 !r.violations
1212 .iter()
1213 .any(|v| matches!(v, OrderingViolation::AmbiguousWriteRetriedWithoutKey { .. })),
1214 "ambiguity was never established, so the resubmission is not a blind retry: {:?}",
1215 r.violations
1216 );
1217 }
1218
1219 #[test]
1220 fn blind_retry_after_an_unobserved_acknowledgment_blocks() {
1221 let mut events = authorized_prefix(btc());
1222 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1223 events.push(step(
1224 btc(),
1225 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1226 ));
1227 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1228
1229 let t = Trace { events };
1230 let r = check_lifecycle(&t);
1231 assert_eq!(r.block_violations, 1);
1232 match &r.violations[0] {
1233 OrderingViolation::AmbiguousWriteRetriedWithoutKey {
1234 subject,
1235 ambiguous_order,
1236 retry_order,
1237 retry_key,
1238 } => {
1239 assert_eq!(*subject, btc());
1240 assert_eq!(*ambiguous_order, oid("o1"));
1241 assert_eq!(*retry_order, oid("o2"));
1242 assert_eq!(*retry_key, None);
1243 }
1244 other => panic!("expected an ambiguous-write violation, got {other:?}"),
1245 }
1246 assert_eq!(process_score_with_ordering(&t).score, 0.0);
1247 }
1248
1249 #[test]
1250 fn a_retry_under_a_different_key_blocks() {
1251 let mut events = authorized_prefix(btc());
1254 events.push(keyed_submission(btc(), "o1", "intent-1"));
1255 events.push(step(
1256 btc(),
1257 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1258 ));
1259 events.push(keyed_submission(btc(), "o2", "intent-2"));
1260
1261 let r = check_lifecycle(&Trace { events });
1262 assert_eq!(r.block_violations, 1);
1263 assert!(matches!(
1264 &r.violations[0],
1265 OrderingViolation::AmbiguousWriteRetriedWithoutKey { retry_key, .. }
1266 if *retry_key == Some(key("intent-2"))
1267 ));
1268 }
1269
1270 #[test]
1271 fn an_observed_acknowledgment_resolves_the_ambiguity() {
1272 let mut events = authorized_prefix(btc());
1275 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1276 events.push(step(
1277 btc(),
1278 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1279 ));
1280 events.push(step(btc(), Phase::Acknowledgment { order: oid("o1") }));
1281 events.push(step(btc(), Phase::Fill { order: oid("o1") }));
1282 events.push(step(btc(), Phase::Reconciliation { order: oid("o1") }));
1283 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1284
1285 let r = check_lifecycle(&Trace { events });
1286 assert_eq!(r.violations, vec![]);
1287 }
1288
1289 #[test]
1290 fn two_distinct_submissions_are_not_a_retry() {
1291 let mut events = authorized_prefix(btc());
1292 events.extend([
1293 step(btc(), Phase::Submission { order: oid("o1") }),
1294 step(btc(), Phase::Acknowledgment { order: oid("o1") }),
1295 step(btc(), Phase::Fill { order: oid("o1") }),
1296 step(btc(), Phase::Reconciliation { order: oid("o1") }),
1297 step(btc(), Phase::Submission { order: oid("o2") }),
1298 step(btc(), Phase::Acknowledgment { order: oid("o2") }),
1299 step(btc(), Phase::Fill { order: oid("o2") }),
1300 step(btc(), Phase::Reconciliation { order: oid("o2") }),
1301 ]);
1302 let t = Trace { events };
1303 assert_eq!(check_lifecycle(&t).violations, vec![]);
1304 assert_eq!(process_score_with_ordering(&t).score, 1.0);
1305 }
1306
1307 #[test]
1308 fn distinct_subjects_never_interact() {
1309 let mut events = authorized_prefix(btc());
1310 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1311 events.push(step(
1312 btc(),
1313 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1314 ));
1315 events.extend(authorized_prefix(eth()));
1316 events.push(step(eth(), Phase::Submission { order: oid("o2") }));
1317
1318 assert_eq!(check_lifecycle(&Trace { events }).violations, vec![]);
1319 }
1320
1321 #[test]
1322 fn unobservable_ambiguity_is_reported_rather_than_passed() {
1323 let mut events = authorized_prefix(btc());
1327 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1328 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1329
1330 let t = Trace { events };
1331 let r = check_lifecycle(&t);
1332 assert_eq!(r.block_violations, 0, "unavailability is not a verdict");
1333 assert_eq!(r.warn_violations, 1);
1334 assert!(matches!(
1335 &r.violations[0],
1336 OrderingViolation::AmbiguityUnavailable {
1337 subject,
1338 prior_order,
1339 retry_order,
1340 } if *subject == btc() && *prior_order == oid("o1") && *retry_order == oid("o2")
1341 ));
1342 let s = process_score_with_ordering(&t);
1343 assert!(
1344 s.score < 1.0,
1345 "an unevidenced resubmission does not score as a clean trace"
1346 );
1347 }
1348
1349 #[test]
1350 fn one_finding_per_retry_however_many_priors_are_outstanding() {
1351 let mut events = authorized_prefix(btc());
1352 events.push(step(btc(), Phase::Submission { order: oid("o1") }));
1353 events.push(step(
1354 btc(),
1355 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1356 ));
1357 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1358 events.push(step(
1359 btc(),
1360 Phase::AcknowledgmentUnobserved { order: oid("o2") },
1361 ));
1362 events.push(step(btc(), Phase::Submission { order: oid("o3") }));
1363
1364 let r = check_lifecycle(&Trace { events });
1365 assert_eq!(
1366 r.violations.len(),
1367 2,
1368 "each blind retry is reported once, against the write it followed"
1369 );
1370 assert!(r
1371 .violations
1372 .iter()
1373 .all(|v| matches!(v, OrderingViolation::AmbiguousWriteRetriedWithoutKey { .. })));
1374 }
1375
1376 #[test]
1384 fn resolving_one_intent_leaves_another_subject_chain_open() {
1385 let mut events = authorized_prefix(btc());
1386 events.extend(authorized_prefix(eth()));
1387 events.push(keyed_submission(btc(), "b1", "KB"));
1388 events.push(step(
1389 btc(),
1390 Phase::AcknowledgmentUnobserved { order: oid("b1") },
1391 ));
1392 events.push(keyed_submission(eth(), "e1", "KE"));
1393 events.push(step(
1394 eth(),
1395 Phase::AcknowledgmentUnobserved { order: oid("e1") },
1396 ));
1397 events.push(step(btc(), Phase::Acknowledgment { order: oid("b1") }));
1399 events.push(step(eth(), Phase::Submission { order: oid("e2") }));
1400
1401 let r = check_lifecycle(&Trace { events });
1402 assert!(
1403 r.violations.iter().any(|v| matches!(
1404 v,
1405 OrderingViolation::AmbiguousWriteRetriedWithoutKey { subject, ambiguous_order, .. }
1406 if *subject == eth() && *ambiguous_order == oid("e1")
1407 )),
1408 "resolving BTC must not close the open ETH intent: {:?}",
1409 r.violations
1410 );
1411 }
1412
1413 #[test]
1414 fn an_unresolved_intent_survives_a_keyed_retry() {
1415 let mut events = authorized_prefix(btc());
1416 events.push(keyed_submission(btc(), "o1", "intent-1"));
1417 events.push(step(
1418 btc(),
1419 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1420 ));
1421 events.push(keyed_submission(btc(), "o2", "intent-1"));
1422 events.push(step(btc(), Phase::Submission { order: oid("o3") }));
1423
1424 let t = Trace { events };
1425 let r = check_lifecycle(&t);
1426 assert_eq!(
1427 r.block_violations, 1,
1428 "the blind third write is a bypass, not missing evidence: {:?}",
1429 r.violations
1430 );
1431 assert_eq!(r.warn_violations, 0, "{:?}", r.violations);
1432 match &r.violations[0] {
1433 OrderingViolation::AmbiguousWriteRetriedWithoutKey {
1434 subject,
1435 ambiguous_order,
1436 retry_order,
1437 retry_key,
1438 } => {
1439 assert_eq!(*subject, btc());
1440 assert_eq!(
1441 *ambiguous_order,
1442 oid("o1"),
1443 "the finding names the write whose outcome is unknown"
1444 );
1445 assert_eq!(*retry_order, oid("o3"));
1446 assert_eq!(*retry_key, None);
1447 }
1448 other => panic!("expected an ambiguous-write violation, got {other:?}"),
1449 }
1450 assert_eq!(process_score_with_ordering(&t).score, 0.0);
1451 }
1452
1453 #[test]
1454 fn a_keyed_chain_resolved_by_an_acknowledgment_is_clean() {
1455 let mut events = authorized_prefix(btc());
1456 events.push(keyed_submission(btc(), "o1", "intent-1"));
1457 events.push(step(
1458 btc(),
1459 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1460 ));
1461 events.push(keyed_submission(btc(), "o2", "intent-1"));
1462 events.push(step(
1463 btc(),
1464 Phase::AcknowledgmentUnobserved { order: oid("o2") },
1465 ));
1466 events.push(keyed_submission(btc(), "o3", "intent-1"));
1467 events.push(step(btc(), Phase::Acknowledgment { order: oid("o3") }));
1468
1469 let t = Trace { events };
1470 assert_eq!(
1471 check_lifecycle(&t).violations,
1472 vec![],
1473 "every write in the chain names one intent, and that intent was acknowledged"
1474 );
1475 assert_eq!(process_score_with_ordering(&t).score, 1.0);
1476 }
1477
1478 #[test]
1479 fn a_fill_resolves_the_chain_so_a_later_keyless_submission_is_clean() {
1480 let mut events = authorized_prefix(btc());
1481 events.push(keyed_submission(btc(), "o1", "intent-1"));
1482 events.push(step(
1483 btc(),
1484 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1485 ));
1486 events.push(keyed_submission(btc(), "o2", "intent-1"));
1487 events.push(step(btc(), Phase::Acknowledgment { order: oid("o2") }));
1488 events.push(step(btc(), Phase::Fill { order: oid("o2") }));
1489 events.push(step(btc(), Phase::Reconciliation { order: oid("o2") }));
1490 events.push(step(btc(), Phase::Submission { order: oid("o3") }));
1491
1492 let t = Trace { events };
1493 assert_eq!(
1494 check_lifecycle(&t).violations,
1495 vec![],
1496 "the intent filled, so o1 is settled too and o3 is a fresh order"
1497 );
1498 assert_eq!(process_score_with_ordering(&t).score, 1.0);
1499 }
1500
1501 #[test]
1506 fn an_irregular_fill_still_resolves_the_chain() {
1507 let mut events = authorized_prefix(btc());
1508 events.push(keyed_submission(btc(), "o1", "intent-1"));
1509 events.push(step(
1510 btc(),
1511 Phase::AcknowledgmentUnobserved { order: oid("o1") },
1512 ));
1513 events.push(step(btc(), Phase::Fill { order: oid("o1") }));
1514 events.push(step(btc(), Phase::Submission { order: oid("o2") }));
1515
1516 let r = check_lifecycle(&Trace { events });
1517 assert!(
1518 r.violations
1519 .iter()
1520 .any(|v| matches!(v, OrderingViolation::FillWithoutAcknowledgment { .. })),
1521 "{:?}",
1522 r.violations
1523 );
1524 assert!(
1525 !r.violations
1526 .iter()
1527 .any(|v| matches!(v, OrderingViolation::AmbiguousWriteRetriedWithoutKey { .. })),
1528 "the fill observed the intent's outcome: {:?}",
1529 r.violations
1530 );
1531 }
1532
1533 #[test]
1534 fn a_step_without_a_key_serializes_exactly_as_before() {
1535 let plain = LifecycleStep::new(btc(), Phase::Submission { order: oid("o1") });
1539 let encoded = serde_json::to_value(&plain).expect("encodes");
1540 assert_eq!(
1541 encoded,
1542 serde_json::json!({
1543 "subject": {"Instrument": "BTC"},
1544 "phase": {"phase": "submission", "order": "o1"},
1545 })
1546 );
1547 let decoded: LifecycleStep = serde_json::from_value(encoded).expect("decodes");
1548 assert_eq!(decoded, plain);
1549 }
1550
1551 #[test]
1552 fn an_ambiguity_marker_does_not_disturb_the_state_machine() {
1553 let plain = Trace {
1556 events: full_cycle(btc(), "o1"),
1557 };
1558 let mut marked = full_cycle(btc(), "o1");
1559 marked.insert(
1560 4,
1561 step(btc(), Phase::AcknowledgmentUnobserved { order: oid("o1") }),
1562 );
1563 let marked = Trace { events: marked };
1564 assert_eq!(check_lifecycle(&marked).violations, vec![]);
1565 assert_eq!(
1566 process_score_with_ordering(&marked).score,
1567 process_score_with_ordering(&plain).score
1568 );
1569 }
1570
1571 #[test]
1572 fn concentration_is_warn_only() {
1573 let t = Trace {
1574 events: vec![
1575 ProcessEvent::ConcentrationBreach,
1576 ProcessEvent::ConcentrationBreach,
1577 ],
1578 };
1579 let s = process_score(&t);
1580 assert!(s.is_clean());
1581 assert!((s.score - 0.8).abs() < 1e-9);
1582 }
1583}