1use std::collections::{BTreeMap, BTreeSet};
87use std::fmt;
88
89use serde::Serialize;
90use serde_json::Value;
91use sha2::{Digest, Sha256};
92use sharpebench_protocol::canonical::{versioned_preimage, CanonicalError, CANONICAL_JSON_VERSION};
93use sharpebench_protocol::{Decision, MarketObservation, Order};
94
95pub const DECISION_STABILITY_SCHEMA: &str = "sharpebench.decision-stability.v1";
97
98pub const OBSERVATION_DIGEST_RULE: &str =
100 "sha256 over the sharpebench/canonical-json/v1 framed preimage of the MarketObservation";
101
102pub const GROUPING_RULE: &str = "a group is two or more replicate runs of one window that were shown equal observations at this step and at every earlier step, and made the same decisions at every earlier step";
104
105pub const DECISION_DIFFERENCE_RULE: &str = "decisions differ when their orders arrays differ in length or when any position differs in symbol, action, target_weight or confidence (a stated confidence differs from an unstated one), numbers compared exactly with 0 equal to -0; reasoning, rationale and cost are not compared";
107
108pub const RATE_RULE: &str = "pairwise disagreement: replicate pairs with differing decisions over replicate pairs, summed over compared groups, where a group of n replicates holds n(n-1)/2 pairs";
110
111pub const SAMPLING_UNIT: &str = "the replicate run: groups and pairs at later steps reuse the same runs, so their counts are not sample sizes, and the rate carries no interval";
113
114pub fn observation_sha256(observation: &MarketObservation) -> Result<String, CanonicalError> {
122 let value = serde_json::to_value(observation).expect("market observations serialize");
123 if contains_null(&value) {
124 return Err(CanonicalError::NonFinite(format!(
125 "number in the observation dated {}",
126 observation.date
127 )));
128 }
129 let preimage = versioned_preimage(&value)?;
130 Ok(crate::lower_hex(&Sha256::digest(&preimage)))
131}
132
133fn contains_null(value: &Value) -> bool {
134 match value {
135 Value::Null => true,
136 Value::Array(items) => items.iter().any(contains_null),
137 Value::Object(members) => members.values().any(contains_null),
138 _ => false,
139 }
140}
141
142pub fn same_decision(left: &Decision, right: &Decision) -> bool {
144 left.orders.len() == right.orders.len()
145 && left
146 .orders
147 .iter()
148 .zip(&right.orders)
149 .all(|(left, right)| same_order(left, right))
150}
151
152fn same_order(left: &Order, right: &Order) -> bool {
153 left.symbol == right.symbol
154 && left.action == right.action
155 && same_number(left.target_weight, right.target_weight)
156 && same_confidence(left.confidence, right.confidence)
157}
158
159fn same_confidence(left: Option<f64>, right: Option<f64>) -> bool {
160 match (left, right) {
161 (Some(left), Some(right)) => same_number(left, right),
162 (None, None) => true,
163 _ => false,
164 }
165}
166
167fn same_number(left: f64, right: f64) -> bool {
170 left == right || left.to_bits() == right.to_bits()
171}
172
173#[derive(Clone, Debug)]
175pub struct ObservedDecision<'a> {
176 pub observation_sha256: String,
177 pub decision: &'a Decision,
178}
179
180#[derive(Clone, Debug)]
182pub struct ReplicateRun<'a> {
183 pub window_start: usize,
185 pub window_end: usize,
187 pub content_sha256: String,
191 pub steps: Vec<ObservedDecision<'a>>,
192}
193
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub enum IdenticalReplicates {
197 Refused,
199 Declared,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum DecisionStabilityError {
207 IncompleteRun {
210 run: usize,
211 recorded: usize,
212 required: usize,
213 },
214 UndeclaredIdenticalReplicates {
217 window_start: usize,
218 window_end: usize,
219 identical: usize,
220 },
221}
222
223impl fmt::Display for DecisionStabilityError {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 match self {
226 Self::IncompleteRun {
227 run,
228 recorded,
229 required,
230 } => write!(
231 f,
232 "replicate run {run} records {recorded} of the {required} decisions its window requires"
233 ),
234 Self::UndeclaredIdenticalReplicates {
235 window_start,
236 window_end,
237 identical,
238 } => write!(
239 f,
240 "window [{window_start}, {window_end}) holds {identical} replicate runs identical to another replicate; a copied capture agrees with itself, so declare them separate executions or remove the copies"
241 ),
242 }
243 }
244}
245
246impl std::error::Error for DecisionStabilityError {}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
250#[serde(rename_all = "snake_case")]
251pub enum StabilityUnavailable {
252 SingleReplicate,
254 NoMatchedObservation,
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
260#[serde(tag = "status", rename_all = "snake_case")]
261pub enum StabilityRate {
262 Available {
264 value: f64,
265 },
266 Unavailable {
267 reason: StabilityUnavailable,
268 },
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize)]
273pub struct StabilityCounts {
274 pub steps_total: usize,
276 pub steps_compared: usize,
278 pub steps_excluded_diverged_observation: usize,
281 pub steps_excluded_diverged_decision: usize,
284 pub steps_unreplicated: usize,
286 pub identical_replicate_runs: usize,
289 pub groups_compared: usize,
291 pub groups_with_differing_decisions: usize,
293 pub group_sizes: BTreeMap<usize, usize>,
295 pub pairs_compared: usize,
296 pub differing_pairs: usize,
297 pub pairwise_disagreement: StabilityRate,
299}
300
301#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
303pub struct DifferingGroup {
304 pub step: usize,
306 pub observation_sha256: String,
307 pub replicates: usize,
309 pub distinct_decisions: usize,
311 pub differing_pairs: usize,
313}
314
315#[derive(Clone, Debug, PartialEq, Serialize)]
317pub struct WindowStability {
318 pub window_start: usize,
319 pub window_end: usize,
320 pub replicates: usize,
322 #[serde(flatten)]
323 pub counts: StabilityCounts,
324 pub differing_groups: Vec<DifferingGroup>,
326}
327
328#[derive(Clone, Debug, PartialEq, Serialize)]
330pub struct DecisionStabilityReport {
331 pub schema: String,
332 pub agent_id: String,
333 pub rank_input: bool,
335 pub observation_digest: String,
336 pub grouping: String,
337 pub decision_difference: String,
338 pub rate: String,
339 pub sampling_unit: String,
340 pub identical_replicates_declared: bool,
342 pub replicate_runs: usize,
343 #[serde(flatten)]
345 pub totals: StabilityCounts,
346 pub windows: Vec<WindowStability>,
348}
349
350pub fn decision_stability(
359 agent_id: &str,
360 runs: &[ReplicateRun<'_>],
361 identical: IdenticalReplicates,
362) -> Result<DecisionStabilityReport, DecisionStabilityError> {
363 for (index, run) in runs.iter().enumerate() {
364 let required = run.window_end.saturating_sub(run.window_start);
365 if run.steps.len() != required {
366 return Err(DecisionStabilityError::IncompleteRun {
367 run: index,
368 recorded: run.steps.len(),
369 required,
370 });
371 }
372 }
373
374 let mut by_window: BTreeMap<(usize, usize), Vec<&ReplicateRun<'_>>> = BTreeMap::new();
375 for run in runs {
376 by_window
377 .entry((run.window_start, run.window_end))
378 .or_default()
379 .push(run);
380 }
381 let windows: Vec<WindowStability> = by_window
382 .into_iter()
383 .map(|((start, end), replicates)| window_stability(start, end, &replicates))
384 .collect();
385
386 if identical == IdenticalReplicates::Refused {
387 if let Some(window) = windows
388 .iter()
389 .find(|window| window.counts.identical_replicate_runs > 0)
390 {
391 return Err(DecisionStabilityError::UndeclaredIdenticalReplicates {
392 window_start: window.window_start,
393 window_end: window.window_end,
394 identical: window.counts.identical_replicate_runs,
395 });
396 }
397 }
398
399 let mut totals = empty_counts(0);
400 for window in &windows {
401 let counts = &window.counts;
402 totals.steps_total += counts.steps_total;
403 totals.steps_compared += counts.steps_compared;
404 totals.steps_excluded_diverged_observation += counts.steps_excluded_diverged_observation;
405 totals.steps_excluded_diverged_decision += counts.steps_excluded_diverged_decision;
406 totals.steps_unreplicated += counts.steps_unreplicated;
407 totals.identical_replicate_runs += counts.identical_replicate_runs;
408 totals.groups_compared += counts.groups_compared;
409 totals.groups_with_differing_decisions += counts.groups_with_differing_decisions;
410 for (&size, &groups) in &counts.group_sizes {
411 *totals.group_sizes.entry(size).or_insert(0) += groups;
412 }
413 totals.pairs_compared += counts.pairs_compared;
414 totals.differing_pairs += counts.differing_pairs;
415 }
416 let reason = if windows.iter().any(|window| window.replicates >= 2) {
417 StabilityUnavailable::NoMatchedObservation
418 } else {
419 StabilityUnavailable::SingleReplicate
420 };
421 totals.pairwise_disagreement = rate(totals.pairs_compared, totals.differing_pairs, reason);
422
423 Ok(DecisionStabilityReport {
424 schema: DECISION_STABILITY_SCHEMA.to_string(),
425 agent_id: agent_id.to_string(),
426 rank_input: false,
427 observation_digest: format!("{OBSERVATION_DIGEST_RULE} ({CANONICAL_JSON_VERSION})"),
428 grouping: GROUPING_RULE.to_string(),
429 decision_difference: DECISION_DIFFERENCE_RULE.to_string(),
430 rate: RATE_RULE.to_string(),
431 sampling_unit: SAMPLING_UNIT.to_string(),
432 identical_replicates_declared: identical == IdenticalReplicates::Declared,
433 replicate_runs: runs.len(),
434 totals,
435 windows,
436 })
437}
438
439fn empty_counts(steps_total: usize) -> StabilityCounts {
440 StabilityCounts {
441 steps_total,
442 steps_compared: 0,
443 steps_excluded_diverged_observation: 0,
444 steps_excluded_diverged_decision: 0,
445 steps_unreplicated: 0,
446 identical_replicate_runs: 0,
447 groups_compared: 0,
448 groups_with_differing_decisions: 0,
449 group_sizes: BTreeMap::new(),
450 pairs_compared: 0,
451 differing_pairs: 0,
452 pairwise_disagreement: StabilityRate::Unavailable {
453 reason: StabilityUnavailable::SingleReplicate,
454 },
455 }
456}
457
458fn rate(pairs: usize, differing: usize, reason: StabilityUnavailable) -> StabilityRate {
459 if pairs == 0 {
460 StabilityRate::Unavailable { reason }
461 } else {
462 StabilityRate::Available {
463 value: differing as f64 / pairs as f64,
464 }
465 }
466}
467
468fn pairs_among(n: usize) -> usize {
470 n * (n - 1) / 2
471}
472
473fn window_stability(start: usize, end: usize, runs: &[&ReplicateRun<'_>]) -> WindowStability {
474 let steps = end.saturating_sub(start);
475 let mut counts = empty_counts(runs.len() * steps);
476 let distinct_contents: BTreeSet<&str> =
477 runs.iter().map(|run| run.content_sha256.as_str()).collect();
478 counts.identical_replicate_runs = runs.len() - distinct_contents.len();
479 let mut differing_groups = Vec::new();
480 if runs.len() < 2 {
481 counts.steps_unreplicated = counts.steps_total;
482 return WindowStability {
483 window_start: start,
484 window_end: end,
485 replicates: runs.len(),
486 counts,
487 differing_groups,
488 };
489 }
490
491 let mut groups: Vec<Vec<usize>> = vec![(0..runs.len()).collect()];
492 for step in 0..steps {
493 let mut refined = Vec::with_capacity(groups.len());
494 for group in &groups {
495 let mut by_digest: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
496 for &member in group {
497 by_digest
498 .entry(runs[member].steps[step].observation_sha256.as_str())
499 .or_default()
500 .push(member);
501 }
502 for (digest, members) in by_digest {
503 if members.len() < 2 {
504 counts.steps_excluded_diverged_observation += steps - step;
506 continue;
507 }
508 let classes =
509 decision_classes(&members, |member| runs[member].steps[step].decision);
510 let pairs = pairs_among(members.len());
511 let agreeing: usize = classes.iter().map(|class| pairs_among(class.len())).sum();
512 counts.steps_compared += members.len();
513 counts.groups_compared += 1;
514 *counts.group_sizes.entry(members.len()).or_insert(0) += 1;
515 counts.pairs_compared += pairs;
516 counts.differing_pairs += pairs - agreeing;
517 if classes.len() > 1 {
518 counts.groups_with_differing_decisions += 1;
519 differing_groups.push(DifferingGroup {
520 step,
521 observation_sha256: digest.to_string(),
522 replicates: members.len(),
523 distinct_decisions: classes.len(),
524 differing_pairs: pairs - agreeing,
525 });
526 }
527 for class in classes {
528 if class.len() < 2 {
529 counts.steps_excluded_diverged_decision += steps - step - 1;
532 } else {
533 refined.push(class);
534 }
535 }
536 }
537 }
538 groups = refined;
539 }
540 counts.pairwise_disagreement = rate(
541 counts.pairs_compared,
542 counts.differing_pairs,
543 StabilityUnavailable::NoMatchedObservation,
544 );
545 WindowStability {
546 window_start: start,
547 window_end: end,
548 replicates: runs.len(),
549 counts,
550 differing_groups,
551 }
552}
553
554fn decision_classes<'a>(
556 members: &[usize],
557 decision_of: impl Fn(usize) -> &'a Decision,
558) -> Vec<Vec<usize>> {
559 let mut classes: Vec<Vec<usize>> = Vec::new();
560 for &member in members {
561 let decision = decision_of(member);
562 match classes
563 .iter_mut()
564 .find(|class| same_decision(decision_of(class[0]), decision))
565 {
566 Some(class) => class.push(member),
567 None => classes.push(vec![member]),
568 }
569 }
570 classes
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use sharpebench_protocol::{Action, DecisionCost, PositionState, SymbolSnapshot};
577
578 fn order(symbol: &str, action: Action, target_weight: f64, confidence: f64) -> Order {
579 Order {
580 symbol: symbol.to_string(),
581 action,
582 target_weight,
583 confidence: Some(confidence),
584 rationale: String::new(),
585 }
586 }
587
588 fn decision(orders: Vec<Order>) -> Decision {
589 Decision {
590 orders,
591 reasoning: String::new(),
592 cost: None,
593 }
594 }
595
596 fn buy(weight: f64) -> Decision {
597 decision(vec![order("A", Action::Buy, weight, 0.5)])
598 }
599
600 fn hold() -> Decision {
601 decision(Vec::new())
602 }
603
604 fn run_as<'a>(content: &str, start: usize, steps: &[(&str, &'a Decision)]) -> ReplicateRun<'a> {
607 ReplicateRun {
608 window_start: start,
609 window_end: start + steps.len(),
610 content_sha256: content.to_string(),
611 steps: steps
612 .iter()
613 .map(|&(digest, decision)| ObservedDecision {
614 observation_sha256: digest.to_string(),
615 decision,
616 })
617 .collect(),
618 }
619 }
620
621 fn runs<'a>(start: usize, replicates: &[&[(&str, &'a Decision)]]) -> Vec<ReplicateRun<'a>> {
623 replicates
624 .iter()
625 .enumerate()
626 .map(|(index, steps)| run_as(&format!("run-{start}-{index}"), start, steps))
627 .collect()
628 }
629
630 fn measure(runs: &[ReplicateRun<'_>]) -> DecisionStabilityReport {
631 decision_stability("entrant", runs, IdenticalReplicates::Refused).unwrap()
632 }
633
634 fn available(value: f64) -> StabilityRate {
635 StabilityRate::Available { value }
636 }
637
638 fn observation() -> MarketObservation {
639 MarketObservation {
640 date: "2026-01-02".to_string(),
641 cash: 1.0,
642 symbols: vec![SymbolSnapshot {
643 symbol: "A".to_string(),
644 close_history: vec![10.0, 10.5],
645 fundamentals: [("pe".to_string(), 12.0)].into_iter().collect(),
646 news: vec!["headline".to_string()],
647 }],
648 portfolio: vec![PositionState {
649 symbol: "A".to_string(),
650 shares: 0.0,
651 avg_price: 0.0,
652 }],
653 }
654 }
655
656 #[test]
657 fn the_observation_digest_is_sha256_of_the_canonical_framed_preimage() {
658 let observation = observation();
659 let value = serde_json::to_value(&observation).unwrap();
660 let expected = crate::lower_hex(&Sha256::digest(
661 versioned_preimage(&value).unwrap().as_slice(),
662 ));
663 assert_eq!(observation_sha256(&observation).unwrap(), expected);
664 assert_eq!(expected.len(), 64);
665 }
666
667 #[test]
668 fn the_observation_digest_moves_with_the_observation_and_not_with_the_sign_of_zero() {
669 let base = observation_sha256(&observation()).unwrap();
670 let mut moved = observation();
671 moved.cash = 0.999;
672 assert_ne!(observation_sha256(&moved).unwrap(), base);
673
674 let mut negative_zero = observation();
675 negative_zero.portfolio[0].shares = -0.0;
676 assert_eq!(observation_sha256(&negative_zero).unwrap(), base);
677 }
678
679 #[test]
680 fn a_finite_observation_serializes_without_a_null() {
681 let empty = MarketObservation {
684 date: String::new(),
685 cash: 0.0,
686 symbols: vec![SymbolSnapshot {
687 symbol: String::new(),
688 close_history: Vec::new(),
689 fundamentals: BTreeMap::new(),
690 news: Vec::new(),
691 }],
692 portfolio: Vec::new(),
693 };
694 assert!(!contains_null(&serde_json::to_value(&empty).unwrap()));
695 assert!(observation_sha256(&empty).is_ok());
696 }
697
698 #[test]
699 fn a_non_finite_observation_has_no_digest() {
700 let mut cash = observation();
701 cash.cash = f64::NAN;
702 let mut history = observation();
703 history.symbols[0].close_history[1] = f64::INFINITY;
704 let mut fundamentals = observation();
705 fundamentals.symbols[0]
706 .fundamentals
707 .insert("pe".to_string(), f64::NEG_INFINITY);
708 for refused in [cash, history, fundamentals] {
709 assert!(
710 matches!(
711 observation_sha256(&refused),
712 Err(CanonicalError::NonFinite(ref message)) if message.contains("2026-01-02")
713 ),
714 "a non-finite number must not collapse into a null digest"
715 );
716 }
717 }
718
719 #[test]
720 fn decisions_differ_on_each_score_bearing_order_field() {
721 let base = decision(vec![order("A", Action::Buy, 0.5, 0.7)]);
722 assert!(same_decision(&base, &base.clone()));
723 let mut unstated = base.clone();
724 unstated.orders[0].confidence = None;
725 for changed in [
726 decision(vec![order("B", Action::Buy, 0.5, 0.7)]),
727 decision(vec![order("A", Action::Sell, 0.5, 0.7)]),
728 decision(vec![order("A", Action::Buy, 0.5000001, 0.7)]),
729 decision(vec![order("A", Action::Buy, 0.5, 0.71)]),
730 decision(vec![
731 order("A", Action::Buy, 0.5, 0.7),
732 order("B", Action::Buy, 0.0, 0.7),
733 ]),
734 unstated.clone(),
735 hold(),
736 ] {
737 assert!(!same_decision(&base, &changed), "{changed:?}");
738 assert!(!same_decision(&changed, &base), "{changed:?}");
739 }
740 assert!(
741 same_decision(&unstated, &unstated.clone()),
742 "two unstated confidences are the same statement"
743 );
744 }
745
746 #[test]
747 fn order_position_matters_but_audit_text_and_cost_do_not() {
748 let first = decision(vec![
749 order("A", Action::Buy, 0.5, 0.7),
750 order("B", Action::Sell, -0.2, 0.6),
751 ]);
752 let swapped = decision(vec![first.orders[1].clone(), first.orders[0].clone()]);
753 assert!(!same_decision(&first, &swapped));
754
755 let mut annotated = first.clone();
756 annotated.reasoning = "a different explanation".to_string();
757 annotated.orders[0].rationale = "another rationale".to_string();
758 annotated.cost = Some(DecisionCost {
759 cost_usd: 0.25,
760 tokens_in: 900,
761 tokens_out: 120,
762 reasoning_tokens: 40,
763 });
764 assert!(same_decision(&first, &annotated));
765 }
766
767 #[test]
768 fn numbers_compare_exactly_with_zero_equal_to_negative_zero() {
769 assert!(same_number(0.0, -0.0));
770 assert!(same_number(f64::NAN, f64::NAN));
771 assert!(!same_number(f64::NAN, -f64::NAN));
772 assert!(!same_number(0.3, 0.30000000000000004));
773 assert!(same_decision(&buy(0.0), &buy(-0.0)));
774 assert!(same_confidence(Some(0.0), Some(-0.0)));
775 assert!(!same_confidence(Some(0.5), Some(0.6)));
776 assert!(!same_confidence(None, Some(0.5)));
777 }
778
779 #[test]
780 fn a_deterministic_field_reports_exactly_zero_over_matched_groups() {
781 let (a, b) = (buy(0.5), hold());
782 let replicate: &[(&str, &Decision)] = &[("o0", &a), ("o1", &b), ("o2", &b)];
783 let report = measure(&runs(10, &[replicate, replicate, replicate]));
784 let totals = &report.totals;
785 assert_eq!(totals.groups_compared, 3);
786 assert_eq!(totals.pairs_compared, 9);
787 assert_eq!(totals.differing_pairs, 0);
788 assert_eq!(totals.pairwise_disagreement, available(0.0));
789 assert_eq!(totals.steps_compared, 9);
790 assert_eq!(totals.steps_excluded_diverged_observation, 0);
791 assert_eq!(totals.steps_excluded_diverged_decision, 0);
792 assert_eq!(totals.group_sizes, BTreeMap::from([(3, 3)]));
793 assert!(report.windows[0].differing_groups.is_empty());
794 }
795
796 fn exact_outcome_field(size: usize, weights: &[usize]) -> (Vec<Decision>, Vec<Vec<usize>>) {
802 let decisions: Vec<Decision> = (0..weights.len())
803 .map(|choice| buy(0.1 * (choice + 1) as f64))
804 .collect();
805 let mut arrangements = Vec::new();
806 let mut current = vec![0usize; size];
807 loop {
808 let copies: usize = current.iter().map(|&choice| weights[choice]).product();
809 for _ in 0..copies {
810 arrangements.push(current.clone());
811 }
812 let Some(position) = current
813 .iter()
814 .rposition(|&choice| choice + 1 < weights.len())
815 else {
816 break;
817 };
818 current[position] += 1;
819 for later in &mut current[position + 1..] {
820 *later = 0;
821 }
822 }
823 (decisions, arrangements)
824 }
825
826 fn measure_outcome_field(size: usize, weights: &[usize]) -> DecisionStabilityReport {
827 let (decisions, arrangements) = exact_outcome_field(size, weights);
828 let field: Vec<ReplicateRun<'_>> = arrangements
829 .iter()
830 .enumerate()
831 .flat_map(|(window, choices)| {
832 let decisions = &decisions;
833 choices.iter().enumerate().map(move |(replicate, &choice)| {
834 run_as(
835 &format!("{window}-{replicate}"),
836 window,
837 &[("same", &decisions[choice])],
838 )
839 })
840 })
841 .collect();
842 measure(&field)
843 }
844
845 #[test]
846 fn the_pairwise_rate_is_invariant_to_group_size_under_independent_flips() {
847 let group_share = [
852 (2, 3.0 / 8.0),
853 (3, 9.0 / 16.0),
854 (4, 87.0 / 128.0),
855 (5, 195.0 / 256.0),
856 (6, 1683.0 / 2048.0),
857 ];
858 for (size, share) in group_share {
859 let report = measure_outcome_field(size, &[3, 1]);
860 let totals = &report.totals;
861 assert_eq!(
862 totals.groups_compared,
863 4usize.pow(size as u32),
864 "n = {size}"
865 );
866 assert_eq!(
867 totals.group_sizes,
868 BTreeMap::from([(size, 4usize.pow(size as u32))])
869 );
870 assert_eq!(
871 totals.pairs_compared,
872 totals.groups_compared * size * (size - 1) / 2
873 );
874 assert_eq!(
875 totals.pairwise_disagreement,
876 available(3.0 / 8.0),
877 "n = {size}"
878 );
879 let observed_share =
880 totals.groups_with_differing_decisions as f64 / totals.groups_compared as f64;
881 assert_eq!(observed_share, share, "n = {size}");
882 }
883
884 for size in 2..=5 {
886 let report = measure_outcome_field(size, &[2, 1, 1]);
887 assert_eq!(
888 report.totals.pairwise_disagreement,
889 available(5.0 / 8.0),
890 "n = {size}"
891 );
892 }
893 }
894
895 #[test]
896 fn a_group_counts_every_distinct_decision_and_every_differing_pair() {
897 let (a, b, c) = (buy(0.1), buy(0.2), buy(0.3));
899 let field = runs(
900 0,
901 &[
902 &[("o", &a)],
903 &[("o", &b)],
904 &[("o", &a)],
905 &[("o", &c)],
906 &[("o", &a)],
907 &[("o", &b)],
908 ],
909 );
910 let report = measure(&field);
911 assert_eq!(
912 report.windows[0].differing_groups,
913 vec![DifferingGroup {
914 step: 0,
915 observation_sha256: "o".to_string(),
916 replicates: 6,
917 distinct_decisions: 3,
918 differing_pairs: 11,
919 }]
920 );
921 assert_eq!(report.totals.pairs_compared, 15);
922 assert_eq!(report.totals.differing_pairs, 11);
923 assert_eq!(report.totals.pairwise_disagreement, available(11.0 / 15.0));
924 }
925
926 fn parity_split<'a>(
927 steady: &'a Decision,
928 flipped: &'a Decision,
929 flip_at: impl Fn(usize) -> bool,
930 ) -> Vec<Vec<(String, &'a Decision)>> {
931 (0..4)
932 .map(|replicate| {
933 (0..20)
934 .map(|step| {
935 let chosen = if replicate % 2 == 1 && flip_at(step) {
936 flipped
937 } else {
938 steady
939 };
940 (format!("s{step}"), chosen)
941 })
942 .collect()
943 })
944 .collect()
945 }
946
947 fn measure_owned(replicates: &[Vec<(String, &Decision)>]) -> DecisionStabilityReport {
948 let borrowed: Vec<Vec<(&str, &Decision)>> = replicates
949 .iter()
950 .map(|steps| {
951 steps
952 .iter()
953 .map(|(digest, d)| (digest.as_str(), *d))
954 .collect()
955 })
956 .collect();
957 let slices: Vec<&[(&str, &Decision)]> = borrowed.iter().map(Vec::as_slice).collect();
958 measure(&runs(30, &slices))
959 }
960
961 #[test]
962 fn a_planted_split_on_the_first_step_reports_its_pairwise_rate() {
963 let (steady, flipped) = (buy(0.5), buy(0.25));
967 let report = measure_owned(&parity_split(&steady, &flipped, |step| step == 0));
968 let window = &report.windows[0];
969 assert_eq!(window.counts.pairs_compared, 6 + 19 * 2);
970 assert_eq!(window.counts.differing_pairs, 4);
971 assert_eq!(window.counts.pairwise_disagreement, available(1.0 / 11.0));
972 assert_eq!(window.counts.groups_compared, 1 + 19 * 2);
973 assert_eq!(window.counts.group_sizes, BTreeMap::from([(2, 38), (4, 1)]));
974 assert_eq!(window.counts.steps_compared, 80);
975 assert_eq!(
976 window.differing_groups,
977 vec![DifferingGroup {
978 step: 0,
979 observation_sha256: "s0".to_string(),
980 replicates: 4,
981 distinct_decisions: 2,
982 differing_pairs: 4,
983 }]
984 );
985 }
986
987 #[test]
988 fn a_difference_the_engine_does_not_act_on_is_counted_once() {
989 let (steady, flipped) = (buy(0.5), buy(0.25));
993 let once = measure_owned(&parity_split(&steady, &flipped, |step| step == 0));
994 let repeated = measure_owned(&parity_split(&steady, &flipped, |step| {
995 step.is_multiple_of(4)
996 }));
997 assert_eq!(repeated.windows[0].counts, once.windows[0].counts);
998 assert_eq!(repeated.windows[0].differing_groups.len(), 1);
999 }
1000
1001 #[test]
1002 fn diverged_observations_are_excluded_for_the_rest_of_the_window_and_counted() {
1003 let (x, y) = (buy(0.5), buy(0.9));
1006 let a: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &x)];
1007 let c: &[(&str, &Decision)] = &[("o0", &x), ("fill", &x), ("o2", &y), ("o3", &y)];
1008 let report = measure(&runs(5, &[a, a, c]));
1009 let counts = &report.windows[0].counts;
1010 assert_eq!(counts.steps_total, 12);
1011 assert_eq!(counts.steps_compared, 3 + 2 + 2 + 2);
1012 assert_eq!(counts.steps_excluded_diverged_observation, 3);
1013 assert_eq!(counts.steps_excluded_diverged_decision, 0);
1014 assert_eq!(counts.steps_unreplicated, 0);
1015 assert_eq!(counts.groups_compared, 4);
1016 assert_eq!(counts.differing_pairs, 0);
1017 assert_eq!(counts.pairs_compared, 3 + 1 + 1 + 1);
1018 }
1019
1020 #[test]
1021 fn diverged_decisions_are_compared_once_then_excluded_and_counted() {
1022 let (x, y) = (buy(0.5), buy(0.9));
1025 let a: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &x)];
1026 let c: &[(&str, &Decision)] = &[("o0", &x), ("o1", &y), ("o2", &y), ("o3", &y)];
1027 let report = measure(&runs(5, &[a, a, c]));
1028 let counts = &report.windows[0].counts;
1029 assert_eq!(counts.steps_total, 12);
1030 assert_eq!(counts.steps_compared, 3 + 3 + 2 + 2);
1031 assert_eq!(counts.steps_excluded_diverged_decision, 2);
1032 assert_eq!(counts.steps_excluded_diverged_observation, 0);
1033 assert_eq!(counts.pairs_compared, 3 + 3 + 1 + 1);
1034 assert_eq!(counts.differing_pairs, 2);
1035 assert_eq!(counts.pairwise_disagreement, available(0.25));
1036
1037 let d: &[(&str, &Decision)] = &[("o0", &x), ("o1", &x), ("o2", &x), ("o3", &y)];
1039 let last = measure(&runs(5, &[a, a, d]));
1040 assert_eq!(last.windows[0].counts.steps_excluded_diverged_decision, 0);
1041 assert_eq!(last.windows[0].counts.steps_compared, 12);
1042 }
1043
1044 #[test]
1045 fn a_coincident_observation_after_divergence_is_not_compared() {
1046 let (x, y) = (buy(0.5), buy(0.9));
1050 let left: &[(&str, &Decision)] = &[("o0", &x), ("left", &x), ("o2", &x)];
1051 let right: &[(&str, &Decision)] = &[("o0", &x), ("right", &x), ("o2", &y)];
1052 let report = measure(&runs(0, &[left, right]));
1053 let counts = &report.windows[0].counts;
1054 assert_eq!(counts.groups_compared, 1);
1055 assert_eq!(counts.differing_pairs, 0);
1056 assert_eq!(counts.steps_compared, 2);
1057 assert_eq!(counts.steps_excluded_diverged_observation, 4);
1058 assert_eq!(counts.pairwise_disagreement, available(0.0));
1059 }
1060
1061 #[test]
1062 fn a_split_group_keeps_comparing_each_part() {
1063 let (x, y) = (buy(0.5), buy(0.9));
1064 let a: &[(&str, &Decision)] = &[("o0", &x), ("p", &x), ("p1", &x)];
1065 let b: &[(&str, &Decision)] = &[("o0", &x), ("p", &x), ("p1", &y)];
1066 let c: &[(&str, &Decision)] = &[("o0", &x), ("q", &x), ("q1", &x)];
1067 let report = measure(&runs(0, &[a, b, c, c]));
1068 let window = &report.windows[0];
1069 assert_eq!(window.counts.groups_compared, 5);
1070 assert_eq!(window.counts.group_sizes, BTreeMap::from([(2, 4), (4, 1)]));
1071 assert_eq!(window.counts.groups_with_differing_decisions, 1);
1072 assert_eq!(window.counts.steps_compared, 12);
1073 assert_eq!(window.counts.pairs_compared, 6 + 1 + 1 + 1 + 1);
1074 assert_eq!(window.counts.differing_pairs, 1);
1075 assert_eq!(window.counts.pairwise_disagreement, available(0.1));
1076 assert_eq!(window.differing_groups[0].step, 2);
1077 assert_eq!(window.differing_groups[0].observation_sha256, "p1");
1078 assert_eq!(window.differing_groups[0].replicates, 2);
1079 }
1080
1081 #[test]
1082 fn a_single_replicate_is_typed_unavailable() {
1083 let x = buy(0.5);
1084 let report = measure(&runs(0, &[&[("o0", &x), ("o1", &x)]]));
1085 let single = StabilityRate::Unavailable {
1086 reason: StabilityUnavailable::SingleReplicate,
1087 };
1088 assert_eq!(report.totals.pairwise_disagreement, single);
1089 let window = &report.windows[0];
1090 assert_eq!(window.replicates, 1);
1091 assert_eq!(window.counts.steps_total, 2);
1092 assert_eq!(window.counts.steps_unreplicated, 2);
1093 assert_eq!(window.counts.steps_compared, 0);
1094 assert_eq!(window.counts.steps_excluded_diverged_observation, 0);
1095 assert_eq!(window.counts.groups_compared, 0);
1096 assert_eq!(window.counts.pairs_compared, 0);
1097 assert_eq!(window.counts.pairwise_disagreement, single);
1098
1099 let empty = measure(&[]);
1100 assert_eq!(empty.replicate_runs, 0);
1101 assert!(empty.windows.is_empty());
1102 assert_eq!(empty.totals.pairwise_disagreement, single);
1103 }
1104
1105 #[test]
1106 fn two_replicates_that_never_share_an_observation_are_typed_unavailable() {
1107 let x = buy(0.5);
1108 let report = measure(&runs(0, &[&[("a", &x)], &[("b", &x)]]));
1109 let unmatched = StabilityRate::Unavailable {
1110 reason: StabilityUnavailable::NoMatchedObservation,
1111 };
1112 assert_eq!(report.totals.pairwise_disagreement, unmatched);
1113 assert_eq!(report.windows[0].counts.pairwise_disagreement, unmatched);
1114 assert_eq!(report.totals.steps_excluded_diverged_observation, 2);
1115 }
1116
1117 #[test]
1118 fn windows_are_reported_separately_and_summed() {
1119 let (x, y) = (buy(0.5), buy(0.9));
1120 let mut field = runs(
1121 50,
1122 &[
1123 &[("w", &x), ("w1", &x), ("w2", &x)],
1124 &[("w", &x), ("w1", &y), ("w2", &x)],
1125 &[("w", &x), ("w1", &x), ("other", &x)],
1126 ],
1127 );
1128 field.extend(runs(
1129 0,
1130 &[&[("v", &x), ("v1", &x)], &[("v", &y), ("v1", &x)]],
1131 ));
1132 field.extend(runs(90, &[&[("u", &x)]]));
1133 let report = measure(&field);
1134 assert_eq!(report.replicate_runs, 6);
1135 let spans: Vec<_> = report
1136 .windows
1137 .iter()
1138 .map(|window| (window.window_start, window.window_end, window.replicates))
1139 .collect();
1140 assert_eq!(spans, vec![(0, 2, 2), (50, 53, 3), (90, 91, 1)]);
1141 assert_eq!(
1143 report.windows[0].counts.pairwise_disagreement,
1144 available(1.0)
1145 );
1146 assert_eq!(report.windows[0].counts.steps_excluded_diverged_decision, 2);
1147 let window = &report.windows[1].counts;
1150 assert_eq!(window.pairs_compared, 3 + 3);
1151 assert_eq!(window.differing_pairs, 2);
1152 assert_eq!(window.steps_excluded_diverged_decision, 1);
1153 assert_eq!(window.steps_excluded_diverged_observation, 2);
1154
1155 let totals = &report.totals;
1156 assert_eq!(totals.steps_total, 4 + 9 + 1);
1157 assert_eq!(totals.steps_compared, 2 + 6);
1158 assert_eq!(totals.steps_excluded_diverged_observation, 2);
1159 assert_eq!(totals.steps_excluded_diverged_decision, 2 + 1);
1160 assert_eq!(totals.steps_unreplicated, 1);
1161 assert_eq!(totals.groups_compared, 1 + 2);
1162 assert_eq!(totals.groups_with_differing_decisions, 1 + 1);
1163 assert_eq!(totals.group_sizes, BTreeMap::from([(2, 1), (3, 2)]));
1164 assert_eq!(totals.pairs_compared, 1 + 6);
1165 assert_eq!(totals.differing_pairs, 1 + 2);
1166 assert_eq!(totals.pairwise_disagreement, available(3.0 / 7.0));
1167 }
1168
1169 #[test]
1170 fn an_incomplete_run_is_refused() {
1171 let x = buy(0.5);
1172 let mut field = runs(
1173 0,
1174 &[
1175 &[("o0", &x), ("o1", &x), ("o2", &x)],
1176 &[("o0", &x), ("o1", &x)],
1177 ],
1178 );
1179 field[1].window_end = 3;
1180 let error = decision_stability("short", &field, IdenticalReplicates::Declared).unwrap_err();
1181 assert_eq!(
1182 error,
1183 DecisionStabilityError::IncompleteRun {
1184 run: 1,
1185 recorded: 2,
1186 required: 3,
1187 }
1188 );
1189 assert_eq!(
1190 error.to_string(),
1191 "replicate run 1 records 2 of the 3 decisions its window requires"
1192 );
1193 }
1194
1195 #[test]
1196 fn identical_replicates_are_counted_and_refused_unless_declared() {
1197 let (x, y) = (buy(0.5), buy(0.9));
1198 let steps: &[(&str, &Decision)] = &[("o", &x)];
1199 let other: &[(&str, &Decision)] = &[("o", &y)];
1200 let field = vec![
1201 run_as("copy", 0, steps),
1202 run_as("copy", 0, steps),
1203 run_as("copy", 0, steps),
1204 run_as("fresh", 0, other),
1205 run_as("copy", 7, steps),
1206 run_as("elsewhere", 7, steps),
1207 ];
1208
1209 let refused =
1210 decision_stability("copied", &field, IdenticalReplicates::Refused).unwrap_err();
1211 assert_eq!(
1212 refused,
1213 DecisionStabilityError::UndeclaredIdenticalReplicates {
1214 window_start: 0,
1215 window_end: 1,
1216 identical: 2,
1217 }
1218 );
1219 assert!(refused
1220 .to_string()
1221 .starts_with("window [0, 1) holds 2 replicate runs identical to another replicate"));
1222
1223 let declared = decision_stability("copied", &field, IdenticalReplicates::Declared).unwrap();
1224 assert!(declared.identical_replicates_declared);
1225 assert_eq!(declared.windows[0].counts.identical_replicate_runs, 2);
1226 assert_eq!(declared.windows[1].counts.identical_replicate_runs, 0);
1227 assert_eq!(declared.totals.identical_replicate_runs, 2);
1228 assert_eq!(declared.totals.pairs_compared, 6 + 1);
1231 assert_eq!(declared.totals.differing_pairs, 3);
1232
1233 let distinct = measure(&runs(0, &[steps, steps]));
1234 assert!(!distinct.identical_replicates_declared);
1235 assert_eq!(distinct.totals.identical_replicate_runs, 0);
1236 }
1237
1238 #[test]
1239 fn the_report_is_rank_neutral_and_names_its_rules() {
1240 let x = buy(0.5);
1241 let report = measure(&runs(0, &[&[("o", &x)], &[("o", &x)]]));
1242 let json = serde_json::to_value(&report).unwrap();
1243 assert_eq!(json["schema"], "sharpebench.decision-stability.v1");
1244 assert_eq!(json["agent_id"], "entrant");
1245 assert_eq!(json["rank_input"], false);
1246 assert_eq!(
1247 json["observation_digest"],
1248 format!("{OBSERVATION_DIGEST_RULE} (sharpebench/canonical-json/v1)")
1249 );
1250 assert_eq!(json["grouping"], GROUPING_RULE);
1251 assert_eq!(json["decision_difference"], DECISION_DIFFERENCE_RULE);
1252 assert_eq!(json["rate"], RATE_RULE);
1253 assert_eq!(json["sampling_unit"], SAMPLING_UNIT);
1254 assert_eq!(json["identical_replicates_declared"], false);
1255 assert_eq!(json["groups_compared"], 1);
1256 assert_eq!(json["pairs_compared"], 1);
1257 assert_eq!(json["group_sizes"], serde_json::json!({"2": 1}));
1258 assert_eq!(
1259 json["pairwise_disagreement"],
1260 serde_json::json!({"status": "available", "value": 0.0})
1261 );
1262 assert_eq!(json["windows"][0]["replicates"], 2);
1263 assert_eq!(json["windows"][0]["steps_compared"], 2);
1264 assert_eq!(
1265 serde_json::to_value(StabilityRate::Unavailable {
1266 reason: StabilityUnavailable::NoMatchedObservation
1267 })
1268 .unwrap(),
1269 serde_json::json!({"status": "unavailable", "reason": "no_matched_observation"})
1270 );
1271 }
1272}