@@ -378,6 +378,21 @@ pub fn sort_appraisal_outputs_by_investment_priority(outputs_for_opts: &mut Vec<
378378 } ) ;
379379}
380380
381+ /// Counts the number of top appraisal outputs in a sorted slice that are indistinguishable
382+ /// by both metric and fallback ordering. Excludes the first element from the count.
383+ pub fn count_equal_and_best_appraisal_outputs ( outputs : & [ AppraisalOutput ] ) -> usize {
384+ if outputs. is_empty ( ) {
385+ return 0 ;
386+ }
387+ outputs[ 1 ..]
388+ . iter ( )
389+ . take_while ( |output| {
390+ output. compare_metric ( & outputs[ 0 ] ) . is_eq ( )
391+ && compare_asset_fallback ( & output. asset , & outputs[ 0 ] . asset ) . is_eq ( )
392+ } )
393+ . count ( )
394+ }
395+
381396#[ cfg( test) ]
382397mod tests {
383398 use super :: * ;
@@ -943,4 +958,137 @@ mod tests {
943958 // The invalid output should have been filtered out
944959 assert_eq ! ( outputs. len( ) , 0 ) ;
945960 }
961+
962+ /// Tests for counting number of equal metrics using identical assets so only metric values
963+ /// affect the count.
964+ #[ rstest]
965+ #[ case( vec![ 5.0 ] , 0 , "single_element" ) ]
966+ #[ case( vec![ 5.0 , 5.0 , 5.0 ] , 2 , "all_equal_returns_len_minus_one" ) ]
967+ #[ case( vec![ 1.0 , 2.0 , 3.0 ] , 0 , "none_equal_to_best" ) ]
968+ #[ case( vec![ 5.0 , 5.0 , 9.0 ] , 1 , "partial_equality_stops_at_first_difference" ) ]
969+ #[ case( vec![ 5.0 , 5.0 , 9.0 , 5.0 ] , 1 , "equality_does_not_resume_after_gap" ) ]
970+ fn count_equal_best_lcox_metric (
971+ asset : Asset ,
972+ #[ case] metric_values : Vec < f64 > ,
973+ #[ case] expected_count : usize ,
974+ #[ case] description : & str ,
975+ ) {
976+ let metrics: Vec < Box < dyn MetricTrait > > = metric_values
977+ . into_iter ( )
978+ . map ( |v| Box :: new ( LCOXMetric :: new ( MoneyPerActivity ( v) ) ) as Box < dyn MetricTrait > )
979+ . collect ( ) ;
980+
981+ let outputs =
982+ appraisal_outputs_with_investment_priority_invariant_to_assets ( metrics, & asset) ;
983+
984+ assert_eq ! (
985+ count_equal_and_best_appraisal_outputs( & outputs) ,
986+ expected_count,
987+ "Failed for case: {description}"
988+ ) ;
989+ }
990+
991+ /// Empty slice count should return 0.
992+ #[ test]
993+ fn count_equal_best_empty_slice_returns_zero ( ) {
994+ let outputs: Vec < AppraisalOutput > = vec ! [ ] ;
995+ assert_eq ! ( count_equal_and_best_appraisal_outputs( & outputs) , 0 ) ;
996+ }
997+
998+ /// Equal metrics but differing asset fallback (commissioned vs. candidate) →
999+ /// outputs are distinguishable, so count should be 0.
1000+ #[ rstest]
1001+ fn count_equal_best_equal_metric_different_fallback_returns_zero (
1002+ process : Process ,
1003+ region_id : RegionID ,
1004+ agent_id : AgentID ,
1005+ ) {
1006+ let process_rc = Rc :: new ( process) ;
1007+ let capacity = Capacity ( 10.0 ) ;
1008+
1009+ let commissioned = Asset :: new_commissioned (
1010+ agent_id. clone ( ) ,
1011+ process_rc. clone ( ) ,
1012+ region_id. clone ( ) ,
1013+ capacity,
1014+ 2020 ,
1015+ )
1016+ . unwrap ( ) ;
1017+ let candidate =
1018+ Asset :: new_candidate ( process_rc. clone ( ) , region_id. clone ( ) , capacity, 2020 ) . unwrap ( ) ;
1019+
1020+ let metric_value = MoneyPerActivity ( 5.0 ) ;
1021+ let outputs = appraisal_outputs (
1022+ vec ! [ commissioned, candidate] ,
1023+ vec ! [
1024+ Box :: new( LCOXMetric :: new( metric_value) ) ,
1025+ Box :: new( LCOXMetric :: new( metric_value) ) ,
1026+ ] ,
1027+ ) ;
1028+
1029+ assert_eq ! ( count_equal_and_best_appraisal_outputs( & outputs) , 0 ) ;
1030+ }
1031+
1032+ /// Equal metrics and equal asset fallback (same commissioned status and commission year) →
1033+ /// the second element is indistinguishable, so count should be 1.
1034+ #[ rstest]
1035+ fn count_equal_best_equal_metric_and_equal_fallback_returns_one (
1036+ process : Process ,
1037+ region_id : RegionID ,
1038+ agent_id : AgentID ,
1039+ ) {
1040+ let process_rc = Rc :: new ( process) ;
1041+ let capacity = Capacity ( 10.0 ) ;
1042+ let year = 2020 ;
1043+
1044+ let asset1 = Asset :: new_commissioned (
1045+ agent_id. clone ( ) ,
1046+ process_rc. clone ( ) ,
1047+ region_id. clone ( ) ,
1048+ capacity,
1049+ year,
1050+ )
1051+ . unwrap ( ) ;
1052+ let asset2 = Asset :: new_commissioned (
1053+ agent_id. clone ( ) ,
1054+ process_rc. clone ( ) ,
1055+ region_id. clone ( ) ,
1056+ capacity,
1057+ year,
1058+ )
1059+ . unwrap ( ) ;
1060+
1061+ let metric_value = MoneyPerActivity ( 5.0 ) ;
1062+ let outputs = appraisal_outputs (
1063+ vec ! [ asset1, asset2] ,
1064+ vec ! [
1065+ Box :: new( LCOXMetric :: new( metric_value) ) ,
1066+ Box :: new( LCOXMetric :: new( metric_value) ) ,
1067+ ] ,
1068+ ) ;
1069+
1070+ assert_eq ! ( count_equal_and_best_appraisal_outputs( & outputs) , 1 ) ;
1071+ }
1072+
1073+ /// Equal NPV metrics and identical assets → second element should be counted.
1074+ #[ rstest]
1075+ fn count_equal_best_equal_npv_metrics ( asset : Asset ) {
1076+ let make_npv = |surplus : f64 , fixed_cost : f64 | {
1077+ Box :: new ( NPVMetric :: new ( ProfitabilityIndex {
1078+ total_annualised_surplus : Money ( surplus) ,
1079+ annualised_fixed_cost : Money ( fixed_cost) ,
1080+ } ) ) as Box < dyn MetricTrait >
1081+ } ;
1082+
1083+ let metrics = vec ! [
1084+ make_npv( 200.0 , 100.0 ) ,
1085+ make_npv( 200.0 , 100.0 ) , // Equal to best
1086+ make_npv( 100.0 , 100.0 ) , // Worse
1087+ ] ;
1088+
1089+ let outputs =
1090+ appraisal_outputs_with_investment_priority_invariant_to_assets ( metrics, & asset) ;
1091+
1092+ assert_eq ! ( count_equal_and_best_appraisal_outputs( & outputs) , 1 ) ;
1093+ }
9461094}
0 commit comments