Syntrex commited on
Commit
d3b6f35
·
1 Parent(s): e5071ce

Add props confidence breakdown diagnostics

Browse files
analytics/props_mapper.py CHANGED
@@ -29,6 +29,14 @@ def build_strikeout_probability_result(*args, **kwargs):
29
  return _build_strikeout_probability_result(*args, **kwargs)
30
 
31
 
 
 
 
 
 
 
 
 
32
  def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
33
  if statcast_df.empty or "player_name" not in statcast_df.columns:
34
  return {}
@@ -105,6 +113,98 @@ def _compute_verdict(
105
  return "pass"
106
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  def _classify_hr_probability_status(
109
  *,
110
  threshold_int: int,
@@ -691,6 +791,7 @@ def map_hr_props_to_model(
691
  "calibrated_hr_prob": probability_result.get("calibrated_hr_prob"),
692
  "pregame_hr_prob": probability_result.get("pregame_hr_prob"),
693
  "probability_mode": probability_result.get("mode"),
 
694
  "is_modeled": is_modeled,
695
  "threshold": threshold_int,
696
  "confidence_score": probability_result.get("confidence_score"),
@@ -721,6 +822,23 @@ def map_hr_props_to_model(
721
  "platoon_hr_adjustment": probability_result.get("platoon_hr_adjustment"),
722
  "trajectory_hr_adjustment": probability_result.get("trajectory_hr_adjustment"),
723
  "rolling_hr_adjustment": probability_result.get("rolling_hr_adjustment"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  "pitcher_reliability": probability_result.get("pitcher_reliability"),
725
  "trend_reliability": probability_result.get("trend_reliability"),
726
  "zone_reliability": probability_result.get("zone_reliability"),
@@ -850,6 +968,20 @@ def map_strikeout_props_to_model(
850
  selection_side=selection_side,
851
  game_row=_build_game_context_from_row(row),
852
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
 
854
  fair_prob = probability_result.get("fair_prob")
855
  if fair_prob is not None and implied is not None:
@@ -873,10 +1005,66 @@ def map_strikeout_props_to_model(
873
  "model_k_prob": fair_prob,
874
  "bet_ev": bet_ev,
875
  "edge": edge,
876
- "confidence_score": probability_result.get("confidence_score"),
877
- "confidence_bucket": probability_result.get("confidence_bucket"),
878
- "confidence_reasons": probability_result.get("confidence_reasons"),
 
 
 
 
 
 
 
 
 
 
 
 
879
  "expected_strikeouts": probability_result.get("expected_strikeouts"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
  "pitcher_swstr_rate": probability_result.get("pitcher_swstr_rate"),
881
  "pitcher_csw_rate": probability_result.get("pitcher_csw_rate"),
882
  "pitcher_ball_rate": probability_result.get("pitcher_ball_rate"),
 
29
  return _build_strikeout_probability_result(*args, **kwargs)
30
 
31
 
32
+ def build_strikeout_probability_result_v2(*args, **kwargs):
33
+ from models.strikeout_probability_engine_v2 import (
34
+ build_strikeout_probability_result_v2 as _build_strikeout_probability_result_v2,
35
+ )
36
+
37
+ return _build_strikeout_probability_result_v2(*args, **kwargs)
38
+
39
+
40
  def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
41
  if statcast_df.empty or "player_name" not in statcast_df.columns:
42
  return {}
 
113
  return "pass"
114
 
115
 
116
+ def _confidence_display_remap(raw_score: float | None) -> float | None:
117
+ try:
118
+ raw = float(raw_score)
119
+ except Exception:
120
+ return None
121
+ if raw <= 40.0:
122
+ return max(1.0, min(100.0, raw))
123
+ return max(1.0, min(100.0, 40.0 + ((raw - 40.0) * 1.45)))
124
+
125
+
126
+ def _normalize_confidence_components(value: Any) -> list[dict[str, Any]]:
127
+ if not isinstance(value, list):
128
+ return []
129
+ normalized: list[dict[str, Any]] = []
130
+ for item in value:
131
+ if not isinstance(item, dict):
132
+ continue
133
+ label = str(item.get("label") or "").strip()
134
+ if not label:
135
+ continue
136
+ try:
137
+ component_value = float(item.get("value") or 0.0)
138
+ except Exception:
139
+ component_value = 0.0
140
+ normalized.append(
141
+ {
142
+ "label": label,
143
+ "value": round(component_value, 1),
144
+ "direction": str(item.get("direction") or "").strip().lower() or None,
145
+ }
146
+ )
147
+ return normalized
148
+
149
+
150
+ def _select_confidence_primary_driver(
151
+ penalties: list[dict[str, Any]],
152
+ bonuses: list[dict[str, Any]],
153
+ ) -> dict[str, Any] | None:
154
+ penalty_candidates = [item for item in penalties if float(item.get("value") or 0.0) > 0.0]
155
+ bonus_candidates = [item for item in bonuses if float(item.get("value") or 0.0) > 0.0]
156
+ if penalty_candidates:
157
+ return max(penalty_candidates, key=lambda item: float(item.get("value") or 0.0))
158
+ if bonus_candidates:
159
+ return max(bonus_candidates, key=lambda item: float(item.get("value") or 0.0))
160
+ return None
161
+
162
+
163
+ def _build_strikeout_confidence_payload(
164
+ probability_result: dict[str, Any],
165
+ probability_result_v2: dict[str, Any],
166
+ ) -> dict[str, Any]:
167
+ source = "strikeout_v2_shadow" if probability_result_v2.get("confidence_score_v2") is not None else "strikeout_v1_live"
168
+ if source == "strikeout_v2_shadow":
169
+ raw_score = probability_result_v2.get("confidence_score_raw_v2", probability_result_v2.get("confidence_score_v2"))
170
+ raw_bucket = probability_result_v2.get("confidence_bucket_v2")
171
+ reasons = list(probability_result_v2.get("confidence_reasons_v2") or [])
172
+ bonuses = _normalize_confidence_components(probability_result_v2.get("confidence_component_bonuses_v2"))
173
+ penalties = _normalize_confidence_components(probability_result_v2.get("confidence_component_penalties_v2"))
174
+ else:
175
+ raw_score = probability_result.get("confidence_score_raw", probability_result.get("confidence_score"))
176
+ raw_bucket = probability_result.get("confidence_bucket")
177
+ reasons = list(probability_result.get("confidence_reasons") or [])
178
+ bonuses = _normalize_confidence_components(probability_result.get("confidence_component_bonuses"))
179
+ penalties = _normalize_confidence_components(probability_result.get("confidence_component_penalties"))
180
+
181
+ raw_score_float = float(raw_score) if raw_score is not None else None
182
+ display_score = _confidence_display_remap(raw_score_float)
183
+ display_bucket = None
184
+ if display_score is not None:
185
+ if display_score >= 75:
186
+ display_bucket = "high"
187
+ elif display_score >= 55:
188
+ display_bucket = "medium"
189
+ else:
190
+ display_bucket = "low"
191
+ primary_driver = _select_confidence_primary_driver(penalties, bonuses)
192
+ summary_label = str((primary_driver or {}).get("label") or "").strip() or None
193
+
194
+ return {
195
+ "confidence_score_raw": round(raw_score_float, 1) if raw_score_float is not None else None,
196
+ "confidence_score_display": round(display_score, 1) if display_score is not None else None,
197
+ "confidence_source": source,
198
+ "confidence_component_bonuses": bonuses,
199
+ "confidence_component_penalties": penalties,
200
+ "confidence_primary_driver": primary_driver,
201
+ "confidence_summary_label": summary_label,
202
+ "confidence_bucket_raw": raw_bucket,
203
+ "confidence_bucket_display": display_bucket,
204
+ "confidence_reasons": reasons[:5],
205
+ }
206
+
207
+
208
  def _classify_hr_probability_status(
209
  *,
210
  threshold_int: int,
 
791
  "calibrated_hr_prob": probability_result.get("calibrated_hr_prob"),
792
  "pregame_hr_prob": probability_result.get("pregame_hr_prob"),
793
  "probability_mode": probability_result.get("mode"),
794
+ "formula_version": probability_result.get("formula_version"),
795
  "is_modeled": is_modeled,
796
  "threshold": threshold_int,
797
  "confidence_score": probability_result.get("confidence_score"),
 
822
  "platoon_hr_adjustment": probability_result.get("platoon_hr_adjustment"),
823
  "trajectory_hr_adjustment": probability_result.get("trajectory_hr_adjustment"),
824
  "rolling_hr_adjustment": probability_result.get("rolling_hr_adjustment"),
825
+ "damage_zone_alignment_subscore": probability_result.get("damage_zone_alignment_subscore"),
826
+ "pitch_mix_exposure_subscore": probability_result.get("pitch_mix_exposure_subscore"),
827
+ "tunnel_damage_subscore": probability_result.get("tunnel_damage_subscore"),
828
+ "count_pattern_damage_subscore": probability_result.get("count_pattern_damage_subscore"),
829
+ "handedness_damage_subscore": probability_result.get("handedness_damage_subscore"),
830
+ "arsenal_fit_subscore": probability_result.get("arsenal_fit_subscore"),
831
+ "environment_amplification_subscore": probability_result.get("environment_amplification_subscore"),
832
+ "hr_opportunity_projection": probability_result.get("hr_opportunity_projection"),
833
+ "matchup_coverage_confidence": probability_result.get("matchup_coverage_confidence"),
834
+ "component_source_map": probability_result.get("component_source_map"),
835
+ "expected_pitch_mix_by_count": probability_result.get("expected_pitch_mix_by_count"),
836
+ "expected_zone_mix_by_count": probability_result.get("expected_zone_mix_by_count"),
837
+ "expected_pitch_zone_mix_by_count": probability_result.get("expected_pitch_zone_mix_by_count"),
838
+ "tunnel_pair_scores": probability_result.get("tunnel_pair_scores"),
839
+ "predicted_attack_regions": probability_result.get("predicted_attack_regions"),
840
+ "predicted_damage_regions": probability_result.get("predicted_damage_regions"),
841
+ "predicted_whiff_regions": probability_result.get("predicted_whiff_regions"),
842
  "pitcher_reliability": probability_result.get("pitcher_reliability"),
843
  "trend_reliability": probability_result.get("trend_reliability"),
844
  "zone_reliability": probability_result.get("zone_reliability"),
 
968
  selection_side=selection_side,
969
  game_row=_build_game_context_from_row(row),
970
  )
971
+ probability_result_v2 = build_strikeout_probability_result_v2(
972
+ pitcher_statcast_df=pitcher_df,
973
+ pitcher_name=pitcher_name,
974
+ batter_statcast_df=batter_statcast_df,
975
+ opponent_batters=opponent_batters,
976
+ opponent_team=opponent_team,
977
+ line=float(line) if line is not None and str(line).strip() not in {"", "nan", "None"} else None,
978
+ selection_side=selection_side,
979
+ game_row=_build_game_context_from_row(row),
980
+ )
981
+ confidence_payload = _build_strikeout_confidence_payload(
982
+ probability_result=probability_result,
983
+ probability_result_v2=probability_result_v2,
984
+ )
985
 
986
  fair_prob = probability_result.get("fair_prob")
987
  if fair_prob is not None and implied is not None:
 
1005
  "model_k_prob": fair_prob,
1006
  "bet_ev": bet_ev,
1007
  "edge": edge,
1008
+ "confidence_score": confidence_payload.get("confidence_score_display"),
1009
+ "confidence_bucket": confidence_payload.get("confidence_bucket_display"),
1010
+ "confidence_reasons": confidence_payload.get("confidence_reasons"),
1011
+ "confidence_score_raw": confidence_payload.get("confidence_score_raw"),
1012
+ "confidence_score_display": confidence_payload.get("confidence_score_display"),
1013
+ "confidence_source": confidence_payload.get("confidence_source"),
1014
+ "confidence_component_bonuses": confidence_payload.get("confidence_component_bonuses"),
1015
+ "confidence_component_penalties": confidence_payload.get("confidence_component_penalties"),
1016
+ "confidence_primary_driver": confidence_payload.get("confidence_primary_driver"),
1017
+ "confidence_summary_label": confidence_payload.get("confidence_summary_label"),
1018
+ "confidence_bucket_raw": confidence_payload.get("confidence_bucket_raw"),
1019
+ "confidence_bucket_display": confidence_payload.get("confidence_bucket_display"),
1020
+ "confidence_score_v1": probability_result.get("confidence_score"),
1021
+ "confidence_bucket_v1": probability_result.get("confidence_bucket"),
1022
+ "confidence_score_raw_v1": probability_result.get("confidence_score_raw", probability_result.get("confidence_score")),
1023
  "expected_strikeouts": probability_result.get("expected_strikeouts"),
1024
+ "expected_strikeouts_v2": probability_result_v2.get("expected_strikeouts_v2"),
1025
+ "projected_pitch_count": probability_result_v2.get("projected_pitch_count"),
1026
+ "projected_batters_faced": probability_result_v2.get("projected_batters_faced"),
1027
+ "projected_innings": probability_result_v2.get("projected_innings"),
1028
+ "projected_k_rate": probability_result_v2.get("projected_k_rate"),
1029
+ "fair_prob_v2": probability_result_v2.get("fair_prob_v2"),
1030
+ "raw_k_prob_v2": probability_result_v2.get("raw_k_prob_v2"),
1031
+ "calibrated_k_prob_v2": probability_result_v2.get("calibrated_k_prob_v2"),
1032
+ "confidence_score_v2": probability_result_v2.get("confidence_score_v2"),
1033
+ "confidence_score_raw_v2": probability_result_v2.get("confidence_score_raw_v2"),
1034
+ "confidence_score_display_v2": probability_result_v2.get("confidence_score_display_v2"),
1035
+ "confidence_source_v2": probability_result_v2.get("confidence_source_v2"),
1036
+ "confidence_bucket_v2": probability_result_v2.get("confidence_bucket_v2"),
1037
+ "confidence_reasons_v2": probability_result_v2.get("confidence_reasons_v2"),
1038
+ "confidence_component_bonuses_v2": probability_result_v2.get("confidence_component_bonuses_v2"),
1039
+ "confidence_component_penalties_v2": probability_result_v2.get("confidence_component_penalties_v2"),
1040
+ "confidence_primary_driver_v2": probability_result_v2.get("confidence_primary_driver_v2"),
1041
+ "confidence_summary_label_v2": probability_result_v2.get("confidence_summary_label_v2"),
1042
+ "k_rate_pitch_signal": probability_result_v2.get("k_rate_pitch_signal"),
1043
+ "k_rate_anchor": probability_result_v2.get("k_rate_anchor"),
1044
+ "bb_rate_anchor": probability_result_v2.get("bb_rate_anchor"),
1045
+ "command_efficiency_signal": probability_result_v2.get("command_efficiency_signal"),
1046
+ "swing_miss_subscore": probability_result_v2.get("swing_miss_subscore"),
1047
+ "called_strike_subscore": probability_result_v2.get("called_strike_subscore"),
1048
+ "command_efficiency_subscore": probability_result_v2.get("command_efficiency_subscore"),
1049
+ "lineup_whiff_subscore": probability_result_v2.get("lineup_whiff_subscore"),
1050
+ "zone_matchup_subscore": probability_result_v2.get("zone_matchup_subscore"),
1051
+ "family_zone_matchup_subscore": probability_result_v2.get("family_zone_matchup_subscore"),
1052
+ "arsenal_fit_subscore": probability_result_v2.get("arsenal_fit_subscore"),
1053
+ "tunneling_subscore": probability_result_v2.get("tunneling_subscore"),
1054
+ "release_consistency_subscore": probability_result_v2.get("release_consistency_subscore"),
1055
+ "sequencing_subscore": probability_result_v2.get("sequencing_subscore"),
1056
+ "count_leverage_subscore": probability_result_v2.get("count_leverage_subscore"),
1057
+ "leash_risk_subscore": probability_result_v2.get("leash_risk_subscore"),
1058
+ "times_through_order_penalty": probability_result_v2.get("times_through_order_penalty"),
1059
+ "variance_band_low": probability_result_v2.get("variance_band_low"),
1060
+ "variance_band_high": probability_result_v2.get("variance_band_high"),
1061
+ "matchup_coverage_confidence": probability_result_v2.get("matchup_coverage_confidence"),
1062
+ "component_source_map": probability_result_v2.get("component_source_map"),
1063
+ "predicted_whiff_regions": probability_result_v2.get("predicted_whiff_regions"),
1064
+ "predicted_attack_regions": probability_result_v2.get("predicted_attack_regions"),
1065
+ "predicted_damage_regions": probability_result_v2.get("predicted_damage_regions"),
1066
+ "tunnel_pair_scores": probability_result_v2.get("tunnel_pair_scores"),
1067
+ "formula_version": probability_result_v2.get("formula_version"),
1068
  "pitcher_swstr_rate": probability_result.get("pitcher_swstr_rate"),
1069
  "pitcher_csw_rate": probability_result.get("pitcher_csw_rate"),
1070
  "pitcher_ball_rate": probability_result.get("pitcher_ball_rate"),
models/hr_probability_engine.py CHANGED
@@ -14,6 +14,7 @@ from models.rolling_form_model import (
14
  build_pitcher_rolling_form_row,
15
  compute_upcoming_rolling_adjustment,
16
  )
 
17
  from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
18
 
19
 
@@ -46,6 +47,7 @@ def _empty_result(player_name: str, mode: str) -> dict[str, Any]:
46
  "player_name": player_name,
47
  "pitcher_name": "",
48
  "mode": mode,
 
49
  "baseline_hr_prob": None,
50
  "adjusted_hr_prob": None,
51
  "raw_hr_prob": None,
@@ -103,6 +105,23 @@ def _empty_result(player_name: str, mode: str) -> dict[str, Any]:
103
  "trajectory_reliability": 0.0,
104
  "rolling_reliability": 0.0,
105
  "opportunity_reliability": 0.0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  "model_voice_reason_candidates": [],
107
  "model_voice_tags": [],
108
  "reason_candidate_count": 0,
@@ -451,6 +470,24 @@ def build_hr_probability_result(
451
  _sample_reliability(batter_pa, 180.0),
452
  _sample_reliability(pitcher_row.get("sample_size"), 180.0),
453
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  zone_eff = 0.0
455
  batter_zone_row: dict[str, Any] = {}
456
  pitcher_zone_row: dict[str, Any] = {}
@@ -515,6 +552,7 @@ def build_hr_probability_result(
515
  "missing_pitcher_zone_profile" if int(_safe_float(pitcher_zone_row.get("zone_sample_size"), 0.0) or 0.0) <= 0 else
516
  "available_zero_effect"
517
  )
 
518
  result["family_zone_status"] = (
519
  "applied" if abs(result["family_zone_hr_adjustment"]) > 1e-6 else
520
  "missing_batter_family_zone_profile" if int(_safe_float(batter_family_zone_row.get("family_zone_sample_size"), 0.0) or 0.0) <= 0 else
@@ -573,6 +611,15 @@ def build_hr_probability_result(
573
  "missing_pitcher_arsenal_profile" if int(_safe_float(pitcher_arsenal_row.get("arsenal_sample_size"), 0.0) or 0.0) <= 0 else
574
  "available_zero_effect"
575
  )
 
 
 
 
 
 
 
 
 
576
  hr_prob = _clamp(hr_prob + result["arsenal_hr_adjustment"], 0.005, 0.25)
577
  if abs(result["arsenal_hr_adjustment"]) > 1e-6:
578
  applied_layers.append("arsenal")
@@ -585,6 +632,7 @@ def build_hr_probability_result(
585
  )
586
 
587
  result["platoon_hr_adjustment"] = platoon_adj
 
588
  hr_prob = _clamp(hr_prob + platoon_adj, 0.005, 0.25)
589
  if abs(platoon_adj) > 1e-6:
590
  applied_layers.append("platoon")
@@ -659,6 +707,7 @@ def build_hr_probability_result(
659
  _safe_float(traj_adj.get("hr_adj")),
660
  result["trajectory_reliability"],
661
  )
 
662
  hr_prob = _clamp(hr_prob + result["trajectory_hr_adjustment"], 0.005, 0.25)
663
  if abs(result["trajectory_hr_adjustment"]) > 1e-6:
664
  applied_layers.append("trajectory")
@@ -731,6 +780,7 @@ def build_hr_probability_result(
731
  result["pa_multiplier"] = opportunity.get("pa_multiplier")
732
  result["opportunity_mode"] = opportunity.get("opportunity_mode")
733
  result["opportunity_reason"] = opportunity.get("opportunity_reason")
 
734
  if lineup_slot is not None and team_total is not None:
735
  result["opportunity_reliability"] = 1.0 if result["lineup_slot_source"] == "confirmed" else 0.82
736
  elif lineup_slot is not None:
@@ -765,6 +815,7 @@ def build_hr_probability_result(
765
  raw_prob=hr_prob,
766
  baseline_prob=result.get("baseline_hr_prob"),
767
  )
 
768
  if mode == "pregame":
769
  result["pregame_hr_prob"] = result["calibrated_hr_prob"]
770
  else:
 
14
  build_pitcher_rolling_form_row,
15
  compute_upcoming_rolling_adjustment,
16
  )
17
+ from models.shared_matchup_engine import compose_shared_matchup_context
18
  from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
19
 
20
 
 
47
  "player_name": player_name,
48
  "pitcher_name": "",
49
  "mode": mode,
50
+ "formula_version": "hr_v1_shared_matchup",
51
  "baseline_hr_prob": None,
52
  "adjusted_hr_prob": None,
53
  "raw_hr_prob": None,
 
105
  "trajectory_reliability": 0.0,
106
  "rolling_reliability": 0.0,
107
  "opportunity_reliability": 0.0,
108
+ "damage_zone_alignment_subscore": None,
109
+ "pitch_mix_exposure_subscore": None,
110
+ "tunnel_damage_subscore": None,
111
+ "count_pattern_damage_subscore": None,
112
+ "handedness_damage_subscore": None,
113
+ "arsenal_fit_subscore": None,
114
+ "environment_amplification_subscore": None,
115
+ "hr_opportunity_projection": None,
116
+ "matchup_coverage_confidence": None,
117
+ "component_source_map": {},
118
+ "expected_pitch_mix_by_count": {},
119
+ "expected_zone_mix_by_count": {},
120
+ "expected_pitch_zone_mix_by_count": {},
121
+ "tunnel_pair_scores": [],
122
+ "predicted_attack_regions": [],
123
+ "predicted_damage_regions": [],
124
+ "predicted_whiff_regions": [],
125
  "model_voice_reason_candidates": [],
126
  "model_voice_tags": [],
127
  "reason_candidate_count": 0,
 
470
  _sample_reliability(batter_pa, 180.0),
471
  _sample_reliability(pitcher_row.get("sample_size"), 180.0),
472
  )
473
+ shared_matchup = compose_shared_matchup_context(
474
+ batter_name=batter_name,
475
+ pitcher_name=result["pitcher_name"],
476
+ batter_statcast_df=batter_df,
477
+ pitcher_statcast_df=pitcher_df,
478
+ batter_features=batter_features,
479
+ pitcher_row=pitcher_row,
480
+ game_row=game_row,
481
+ )
482
+ result["expected_pitch_mix_by_count"] = shared_matchup.get("expected_pitch_mix_by_count") or {}
483
+ result["expected_zone_mix_by_count"] = shared_matchup.get("expected_zone_mix_by_count") or {}
484
+ result["expected_pitch_zone_mix_by_count"] = shared_matchup.get("expected_pitch_zone_mix_by_count") or {}
485
+ result["tunnel_pair_scores"] = shared_matchup.get("tunnel_pair_scores") or []
486
+ result["predicted_attack_regions"] = shared_matchup.get("predicted_attack_regions") or []
487
+ result["predicted_damage_regions"] = shared_matchup.get("predicted_damage_regions") or []
488
+ result["predicted_whiff_regions"] = shared_matchup.get("predicted_whiff_regions") or []
489
+ result["matchup_coverage_confidence"] = shared_matchup.get("matchup_coverage_confidence")
490
+ result["component_source_map"] = shared_matchup.get("component_source_map") or {}
491
  zone_eff = 0.0
492
  batter_zone_row: dict[str, Any] = {}
493
  pitcher_zone_row: dict[str, Any] = {}
 
552
  "missing_pitcher_zone_profile" if int(_safe_float(pitcher_zone_row.get("zone_sample_size"), 0.0) or 0.0) <= 0 else
553
  "available_zero_effect"
554
  )
555
+ result["damage_zone_alignment_subscore"] = round(_safe_float(zone_matchup_adj.get("hr_zone_boost"), 0.0), 4) if "zone_matchup_adj" in locals() else 0.0
556
  result["family_zone_status"] = (
557
  "applied" if abs(result["family_zone_hr_adjustment"]) > 1e-6 else
558
  "missing_batter_family_zone_profile" if int(_safe_float(batter_family_zone_row.get("family_zone_sample_size"), 0.0) or 0.0) <= 0 else
 
611
  "missing_pitcher_arsenal_profile" if int(_safe_float(pitcher_arsenal_row.get("arsenal_sample_size"), 0.0) or 0.0) <= 0 else
612
  "available_zero_effect"
613
  )
614
+ result["pitch_mix_exposure_subscore"] = round(
615
+ _safe_float(shared_matchup.get("arsenal_matchup", {}).get("arsenal_hr_boost"), 0.0),
616
+ 4,
617
+ )
618
+ result["count_pattern_damage_subscore"] = round(
619
+ sum(float(item.get("score") or 0.0) for item in (result["predicted_damage_regions"] or [])[:3]),
620
+ 4,
621
+ )
622
+ result["arsenal_fit_subscore"] = round(_safe_float(arsenal_matchup_adj.get("arsenal_hr_boost"), 0.0), 4) if "arsenal_matchup_adj" in locals() else 0.0
623
  hr_prob = _clamp(hr_prob + result["arsenal_hr_adjustment"], 0.005, 0.25)
624
  if abs(result["arsenal_hr_adjustment"]) > 1e-6:
625
  applied_layers.append("arsenal")
 
632
  )
633
 
634
  result["platoon_hr_adjustment"] = platoon_adj
635
+ result["handedness_damage_subscore"] = round(_safe_float(platoon_adj, 0.0), 4)
636
  hr_prob = _clamp(hr_prob + platoon_adj, 0.005, 0.25)
637
  if abs(platoon_adj) > 1e-6:
638
  applied_layers.append("platoon")
 
707
  _safe_float(traj_adj.get("hr_adj")),
708
  result["trajectory_reliability"],
709
  )
710
+ result["tunnel_damage_subscore"] = round(_safe_float(trajectory_row.get("tunnel_score"), 0.0), 4)
711
  hr_prob = _clamp(hr_prob + result["trajectory_hr_adjustment"], 0.005, 0.25)
712
  if abs(result["trajectory_hr_adjustment"]) > 1e-6:
713
  applied_layers.append("trajectory")
 
780
  result["pa_multiplier"] = opportunity.get("pa_multiplier")
781
  result["opportunity_mode"] = opportunity.get("opportunity_mode")
782
  result["opportunity_reason"] = opportunity.get("opportunity_reason")
783
+ result["hr_opportunity_projection"] = round(_safe_float(opportunity.get("expected_pa"), 0.0), 3)
784
  if lineup_slot is not None and team_total is not None:
785
  result["opportunity_reliability"] = 1.0 if result["lineup_slot_source"] == "confirmed" else 0.82
786
  elif lineup_slot is not None:
 
815
  raw_prob=hr_prob,
816
  baseline_prob=result.get("baseline_hr_prob"),
817
  )
818
+ result["environment_amplification_subscore"] = round(_safe_float(result["env_hr_adjustment"], 0.0), 4)
819
  if mode == "pregame":
820
  result["pregame_hr_prob"] = result["calibrated_hr_prob"]
821
  else:
models/shared_matchup_engine.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+
7
+ from models.arsenal_matchup_model import compute_arsenal_matchup_adjustment
8
+ from models.batter_arsenal_model import build_batter_arsenal_feature_row
9
+ from models.batter_zone_model import build_batter_zone_feature_row
10
+ from models.family_zone_profile_store import (
11
+ build_batter_family_zone_feature_row,
12
+ build_pitcher_family_zone_feature_row,
13
+ )
14
+ from models.matchup_model import compute_family_zone_matchup_adjustment
15
+ from models.pitch_sequence_model import build_sequence_features, predict_next_pitch_distribution
16
+ from models.pitcher_arsenal_model import build_pitcher_arsenal_feature_row
17
+ from models.pitcher_zone_model import build_pitcher_zone_feature_row
18
+ from models.trajectory_model import build_trajectory_features
19
+ from models.zone_matchup_model import compute_zone_matchup_adjustment
20
+
21
+ _COUNT_STATES: tuple[tuple[int, int], ...] = (
22
+ (0, 0),
23
+ (1, 0),
24
+ (0, 1),
25
+ (1, 1),
26
+ (0, 2),
27
+ (1, 2),
28
+ (2, 2),
29
+ (3, 2),
30
+ )
31
+ _PITCH_FAMILIES: tuple[str, ...] = ("fastball", "breaking", "offspeed")
32
+ _ZONES: tuple[str, ...] = ("heart", "shadow", "chase", "waste")
33
+
34
+
35
+ def _safe_float(value: Any, default: float = 0.0) -> float:
36
+ try:
37
+ if value is None:
38
+ return default
39
+ text = str(value).strip().lower()
40
+ if text in {"", "nan", "none"}:
41
+ return default
42
+ return float(value)
43
+ except Exception:
44
+ return default
45
+
46
+
47
+ def _clamp(value: float, lo: float, hi: float) -> float:
48
+ return max(lo, min(hi, value))
49
+
50
+
51
+ def _reliability(sample_size: Any, k: float) -> float:
52
+ sample = max(0.0, _safe_float(sample_size, 0.0))
53
+ return _clamp(sample / (sample + max(1.0, float(k))), 0.0, 1.0)
54
+
55
+
56
+ def _component_source_map() -> dict[str, dict[str, str]]:
57
+ return {
58
+ "zone_matchup": {"classification": "reuse_as_is", "source_module": "models.zone_matchup_model"},
59
+ "family_zone_matchup": {"classification": "reuse_as_is", "source_module": "models.matchup_model"},
60
+ "arsenal_matchup": {"classification": "reuse_as_is", "source_module": "models.arsenal_matchup_model"},
61
+ "trajectory": {"classification": "reuse_as_is", "source_module": "models.trajectory_model"},
62
+ "sequencing": {"classification": "upgrade_existing_module", "source_module": "models.pitch_sequence_model"},
63
+ "count_context": {"classification": "upgrade_existing_module", "source_module": "models.pitch_sequence_model"},
64
+ "shared_composer": {"classification": "new_source_of_truth_component", "source_module": "models.shared_matchup_engine"},
65
+ }
66
+
67
+
68
+ def _build_pitch_zone_mix(
69
+ sequence_profiles: dict[str, dict[str, Any]],
70
+ ) -> dict[str, float]:
71
+ combined: dict[str, float] = {}
72
+ if not sequence_profiles:
73
+ return combined
74
+
75
+ count_weight = 1.0 / float(len(sequence_profiles))
76
+ for payload in sequence_profiles.values():
77
+ fb = _safe_float(payload.get("fastball_prob"))
78
+ br = _safe_float(payload.get("breaking_prob"))
79
+ os = _safe_float(payload.get("offspeed_prob"))
80
+ zone_probs = payload.get("zone_probs", {}) or {}
81
+ family_probs = {
82
+ "fastball": fb,
83
+ "breaking": br,
84
+ "offspeed": os,
85
+ }
86
+ for family, family_prob in family_probs.items():
87
+ for zone in _ZONES:
88
+ zone_prob = _safe_float(zone_probs.get(zone))
89
+ combined[f"{family}_{zone}"] = combined.get(f"{family}_{zone}", 0.0) + (
90
+ family_prob * zone_prob * count_weight
91
+ )
92
+
93
+ total = sum(combined.values())
94
+ if total > 0:
95
+ for key in list(combined.keys()):
96
+ combined[key] = combined[key] / total
97
+ return combined
98
+
99
+
100
+ def _top_regions(weighted_map: dict[str, float], limit: int = 4) -> list[dict[str, Any]]:
101
+ rows = [
102
+ {"region": key, "score": round(float(val), 6)}
103
+ for key, val in weighted_map.items()
104
+ if float(val) > 0
105
+ ]
106
+ rows.sort(key=lambda item: item["score"], reverse=True)
107
+ return rows[:limit]
108
+
109
+
110
+ def compose_shared_matchup_context(
111
+ *,
112
+ batter_name: str,
113
+ pitcher_name: str,
114
+ batter_statcast_df: pd.DataFrame | None,
115
+ pitcher_statcast_df: pd.DataFrame | None,
116
+ batter_features: dict[str, Any] | None = None,
117
+ pitcher_row: dict[str, Any] | None = None,
118
+ game_row: dict[str, Any] | None = None,
119
+ ) -> dict[str, Any]:
120
+ empty = {
121
+ "expected_pitch_mix_by_count": {},
122
+ "expected_zone_mix_by_count": {},
123
+ "expected_pitch_zone_mix_by_count": {},
124
+ "tunnel_pair_scores": [],
125
+ "predicted_attack_regions": [],
126
+ "predicted_damage_regions": [],
127
+ "predicted_whiff_regions": [],
128
+ "handedness_context": {},
129
+ "count_context_profile": {},
130
+ "matchup_coverage_confidence": 0.0,
131
+ "component_source_map": _component_source_map(),
132
+ "zone_matchup": {},
133
+ "family_zone_matchup": {},
134
+ "arsenal_matchup": {},
135
+ "trajectory": {},
136
+ "sequence_profiles": {},
137
+ }
138
+
139
+ batter_df = batter_statcast_df if batter_statcast_df is not None else pd.DataFrame()
140
+ pitcher_df = pitcher_statcast_df if pitcher_statcast_df is not None else batter_df
141
+ if batter_df.empty or pitcher_df.empty or not batter_name or not pitcher_name:
142
+ return empty
143
+
144
+ batter_features = dict(batter_features or {})
145
+ pitcher_row = dict(pitcher_row or {})
146
+ game_row = dict(game_row or {})
147
+
148
+ batter_zone_row = build_batter_zone_feature_row(batter_df, batter_name)
149
+ pitcher_zone_row = build_pitcher_zone_feature_row(pitcher_df, pitcher_name)
150
+ zone_matchup = compute_zone_matchup_adjustment(batter_zone_row, pitcher_zone_row)
151
+
152
+ batter_family_zone_row = build_batter_family_zone_feature_row(batter_df, batter_name)
153
+ pitcher_family_zone_row = build_pitcher_family_zone_feature_row(pitcher_df, pitcher_name)
154
+ family_zone_matchup = compute_family_zone_matchup_adjustment(
155
+ batter_family_zone_row=batter_family_zone_row,
156
+ pitcher_family_zone_row=pitcher_family_zone_row,
157
+ )
158
+
159
+ batter_arsenal_row = build_batter_arsenal_feature_row(batter_df, batter_name)
160
+ pitcher_arsenal_row = build_pitcher_arsenal_feature_row(pitcher_df, pitcher_name)
161
+ arsenal_matchup = compute_arsenal_matchup_adjustment(
162
+ batter_arsenal_row=batter_arsenal_row,
163
+ pitcher_arsenal_row=pitcher_arsenal_row,
164
+ )
165
+
166
+ trajectory = build_trajectory_features(
167
+ statcast_df=pitcher_df,
168
+ pitcher_name=pitcher_name,
169
+ pitcher_id=game_row.get("pitcher_id"),
170
+ )
171
+
172
+ handedness_context = {
173
+ "batter_stand": str(batter_features.get("batter_stand", "") or "").strip().upper(),
174
+ "pitcher_hand": str(pitcher_row.get("p_throws", "") or "").strip().upper(),
175
+ }
176
+
177
+ count_context_profile: dict[str, dict[str, Any]] = {}
178
+ expected_pitch_mix_by_count: dict[str, dict[str, float]] = {}
179
+ expected_zone_mix_by_count: dict[str, dict[str, float]] = {}
180
+ sequence_profiles: dict[str, dict[str, Any]] = {}
181
+ for balls, strikes in _COUNT_STATES:
182
+ count_key = f"{balls}-{strikes}"
183
+ seq_features = build_sequence_features(
184
+ game_row={**game_row, "balls": balls, "strikes": strikes},
185
+ pitcher_row=pitcher_row,
186
+ batter_row=batter_features,
187
+ pitcher_family_zone_row=pitcher_family_zone_row,
188
+ )
189
+ seq_profile = predict_next_pitch_distribution(seq_features)
190
+ sequence_profiles[count_key] = seq_profile
191
+ expected_pitch_mix_by_count[count_key] = {
192
+ family: round(_safe_float(seq_profile.get(f"{family}_prob")), 6)
193
+ for family in _PITCH_FAMILIES
194
+ }
195
+ expected_zone_mix_by_count[count_key] = {
196
+ zone: round(_safe_float((seq_profile.get("zone_probs") or {}).get(zone)), 6)
197
+ for zone in _ZONES
198
+ }
199
+ leverage = "neutral"
200
+ if strikes >= 2:
201
+ leverage = "putaway"
202
+ elif balls >= 2:
203
+ leverage = "hitter_ahead"
204
+ count_context_profile[count_key] = {
205
+ "balls": balls,
206
+ "strikes": strikes,
207
+ "count_leverage": leverage,
208
+ }
209
+
210
+ expected_pitch_zone_mix_by_count = _build_pitch_zone_mix(sequence_profiles)
211
+
212
+ attack_regions = _top_regions(expected_pitch_zone_mix_by_count, limit=5)
213
+
214
+ damage_region_map: dict[str, float] = {}
215
+ whiff_region_map: dict[str, float] = {}
216
+ for key, attack_weight in expected_pitch_zone_mix_by_count.items():
217
+ try:
218
+ family, zone = key.split("_", 1)
219
+ except ValueError:
220
+ continue
221
+ batter_damage = _safe_float(batter_family_zone_row.get(f"damage_rate_{family}_{zone}"))
222
+ pitcher_damage = _safe_float(pitcher_family_zone_row.get(f"damage_allowed_rate_{family}_{zone}"))
223
+ batter_whiff = _safe_float(batter_family_zone_row.get(f"whiff_rate_{family}_{zone}"))
224
+ pitcher_whiff = _safe_float(pitcher_family_zone_row.get(f"whiff_rate_{family}_{zone}"))
225
+ damage_region_map[key] = attack_weight * ((batter_damage * 0.6) + (pitcher_damage * 0.4))
226
+ whiff_region_map[key] = attack_weight * ((batter_whiff * 0.55) + (pitcher_whiff * 0.45))
227
+
228
+ tunnel_score = _safe_float(trajectory.get("tunnel_score"))
229
+ release_score = _safe_float(trajectory.get("release_consistency_score"))
230
+ tunnel_pair_scores = [
231
+ {
232
+ "pair": "arsenal_tunnel_profile",
233
+ "tunnel_score": round(tunnel_score, 6),
234
+ "release_consistency_score": round(release_score, 6),
235
+ "deception_score": round(_safe_float(trajectory.get("deception_score")), 6),
236
+ }
237
+ ] if (tunnel_score or release_score) else []
238
+
239
+ coverage_signals = [
240
+ _reliability(batter_zone_row.get("zone_sample_size"), 200.0),
241
+ _reliability(pitcher_zone_row.get("zone_sample_size"), 200.0),
242
+ _reliability(batter_family_zone_row.get("family_zone_sample_size"), 220.0),
243
+ _reliability(pitcher_family_zone_row.get("family_zone_sample_size"), 220.0),
244
+ _reliability(batter_arsenal_row.get("arsenal_sample_size"), 180.0),
245
+ _reliability(pitcher_arsenal_row.get("arsenal_sample_size"), 180.0),
246
+ _reliability(trajectory.get("trajectory_sample_size"), 240.0),
247
+ ]
248
+ matchup_coverage_confidence = round(sum(coverage_signals) / len(coverage_signals), 4)
249
+
250
+ return {
251
+ "expected_pitch_mix_by_count": expected_pitch_mix_by_count,
252
+ "expected_zone_mix_by_count": expected_zone_mix_by_count,
253
+ "expected_pitch_zone_mix_by_count": expected_pitch_zone_mix_by_count,
254
+ "tunnel_pair_scores": tunnel_pair_scores,
255
+ "predicted_attack_regions": attack_regions,
256
+ "predicted_damage_regions": _top_regions(damage_region_map, limit=5),
257
+ "predicted_whiff_regions": _top_regions(whiff_region_map, limit=5),
258
+ "handedness_context": handedness_context,
259
+ "count_context_profile": count_context_profile,
260
+ "matchup_coverage_confidence": matchup_coverage_confidence,
261
+ "component_source_map": _component_source_map(),
262
+ "zone_matchup": zone_matchup,
263
+ "family_zone_matchup": family_zone_matchup,
264
+ "arsenal_matchup": arsenal_matchup,
265
+ "trajectory": trajectory,
266
+ "sequence_profiles": sequence_profiles,
267
+ }
models/strikeout_probability_engine.py CHANGED
@@ -68,6 +68,14 @@ def _bucket(score: float) -> str:
68
  return "low"
69
 
70
 
 
 
 
 
 
 
 
 
71
  def _normalize_name(value: Any) -> str:
72
  return " ".join(str(value or "").strip().lower().split())
73
 
@@ -225,8 +233,15 @@ def build_strikeout_probability_result(
225
  "trajectory_release_consistency_score": None,
226
  "sequencing_score": None,
227
  "confidence_score": None,
 
 
 
228
  "confidence_bucket": None,
229
  "confidence_reasons": [],
 
 
 
 
230
  "applied_layers": "",
231
  "skipped_layers": "",
232
  "reason_tags_for": [],
@@ -380,27 +395,52 @@ def build_strikeout_probability_result(
380
 
381
  confidence = 52.0
382
  confidence_reasons: list[str] = []
 
 
383
  if sample_size >= 400:
384
  confidence += 10
 
385
  elif sample_size < 150:
386
  confidence -= 12
387
  confidence_reasons.append("Limited pitcher pitch sample")
 
388
  if opponent_overlay.get("lineup_sample_size", 0) >= 7:
389
  confidence += 8
 
390
  else:
391
  confidence -= 6
392
  confidence_reasons.append("Projected opponent lineup is incomplete")
 
393
  if traj_reliability >= 0.45:
394
  confidence += 5
 
395
  else:
396
  confidence_reasons.append("Trajectory/tunneling sample is thin")
 
397
  if seq_reliability >= 0.40:
398
  confidence += 4
 
399
  else:
400
  confidence_reasons.append("Sequencing signal is still noisy")
 
401
  if abs(calibrated_prob - 0.50) > 0.28:
402
  confidence -= 5
403
  confidence_reasons.append("Fair probability is still high-variance")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  result.update(
406
  {
@@ -417,9 +457,15 @@ def build_strikeout_probability_result(
417
  "trajectory_tunnel_score": tunnel,
418
  "trajectory_release_consistency_score": release_consistency,
419
  "sequencing_score": sequencing_score,
420
- "confidence_score": _clamp(confidence, 1.0, 100.0),
421
- "confidence_bucket": _bucket(_clamp(confidence, 1.0, 100.0)),
 
 
422
  "confidence_reasons": confidence_reasons[:5],
 
 
 
 
423
  "applied_layers": "|".join(applied_layers),
424
  "reason_tags_for": reasons_for[:4],
425
  "reason_tags_against": reasons_against[:4],
 
68
  return "low"
69
 
70
 
71
+ def _confidence_component(label: str, value: float, direction: str) -> dict[str, Any]:
72
+ return {
73
+ "label": label,
74
+ "value": round(float(value), 1),
75
+ "direction": direction,
76
+ }
77
+
78
+
79
  def _normalize_name(value: Any) -> str:
80
  return " ".join(str(value or "").strip().lower().split())
81
 
 
233
  "trajectory_release_consistency_score": None,
234
  "sequencing_score": None,
235
  "confidence_score": None,
236
+ "confidence_score_raw": None,
237
+ "confidence_score_display": None,
238
+ "confidence_source": "strikeout_v1_live",
239
  "confidence_bucket": None,
240
  "confidence_reasons": [],
241
+ "confidence_component_bonuses": [],
242
+ "confidence_component_penalties": [],
243
+ "confidence_primary_driver": None,
244
+ "confidence_summary_label": None,
245
  "applied_layers": "",
246
  "skipped_layers": "",
247
  "reason_tags_for": [],
 
395
 
396
  confidence = 52.0
397
  confidence_reasons: list[str] = []
398
+ confidence_component_bonuses: list[dict[str, Any]] = []
399
+ confidence_component_penalties: list[dict[str, Any]] = []
400
  if sample_size >= 400:
401
  confidence += 10
402
+ confidence_component_bonuses.append(_confidence_component("Strong pitcher sample", 10, "bonus"))
403
  elif sample_size < 150:
404
  confidence -= 12
405
  confidence_reasons.append("Limited pitcher pitch sample")
406
+ confidence_component_penalties.append(_confidence_component("Limited pitcher pitch sample", 12, "penalty"))
407
  if opponent_overlay.get("lineup_sample_size", 0) >= 7:
408
  confidence += 8
409
+ confidence_component_bonuses.append(_confidence_component("Projected lineup mostly complete", 8, "bonus"))
410
  else:
411
  confidence -= 6
412
  confidence_reasons.append("Projected opponent lineup is incomplete")
413
+ confidence_component_penalties.append(_confidence_component("Projected opponent lineup is incomplete", 6, "penalty"))
414
  if traj_reliability >= 0.45:
415
  confidence += 5
416
+ confidence_component_bonuses.append(_confidence_component("Strong telemetry coverage", 5, "bonus"))
417
  else:
418
  confidence_reasons.append("Trajectory/tunneling sample is thin")
419
+ confidence_component_penalties.append(_confidence_component("Trajectory/tunneling sample is thin", 0, "penalty"))
420
  if seq_reliability >= 0.40:
421
  confidence += 4
422
+ confidence_component_bonuses.append(_confidence_component("Sequencing sample is stable", 4, "bonus"))
423
  else:
424
  confidence_reasons.append("Sequencing signal is still noisy")
425
+ confidence_component_penalties.append(_confidence_component("Sequencing signal is still noisy", 0, "penalty"))
426
  if abs(calibrated_prob - 0.50) > 0.28:
427
  confidence -= 5
428
  confidence_reasons.append("Fair probability is still high-variance")
429
+ confidence_component_penalties.append(_confidence_component("Fair probability is still high-variance", 5, "penalty"))
430
+
431
+ confidence_raw = _clamp(confidence, 1.0, 100.0)
432
+ primary_penalty = max(
433
+ [item for item in confidence_component_penalties if float(item.get("value") or 0.0) > 0.0],
434
+ key=lambda item: float(item.get("value") or 0.0),
435
+ default=None,
436
+ )
437
+ primary_bonus = max(
438
+ [item for item in confidence_component_bonuses if float(item.get("value") or 0.0) > 0.0],
439
+ key=lambda item: float(item.get("value") or 0.0),
440
+ default=None,
441
+ )
442
+ primary_driver = primary_penalty or primary_bonus
443
+ summary_label = str((primary_driver or {}).get("label") or "").strip() or None
444
 
445
  result.update(
446
  {
 
457
  "trajectory_tunnel_score": tunnel,
458
  "trajectory_release_consistency_score": release_consistency,
459
  "sequencing_score": sequencing_score,
460
+ "confidence_score": confidence_raw,
461
+ "confidence_score_raw": confidence_raw,
462
+ "confidence_score_display": confidence_raw,
463
+ "confidence_bucket": _bucket(confidence_raw),
464
  "confidence_reasons": confidence_reasons[:5],
465
+ "confidence_component_bonuses": confidence_component_bonuses,
466
+ "confidence_component_penalties": confidence_component_penalties,
467
+ "confidence_primary_driver": primary_driver,
468
+ "confidence_summary_label": summary_label,
469
  "applied_layers": "|".join(applied_layers),
470
  "reason_tags_for": reasons_for[:4],
471
  "reason_tags_against": reasons_against[:4],
models/strikeout_probability_engine_v2.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+ from models.pitcher_adjustment import build_pitcher_feature_row
9
+ from models.shared_matchup_engine import compose_shared_matchup_context
10
+
11
+
12
+ def _safe_float(value: Any, default: float | None = None) -> float | None:
13
+ try:
14
+ if value is None:
15
+ return default
16
+ text = str(value).strip().lower()
17
+ if text in {"", "nan", "none"}:
18
+ return default
19
+ return float(value)
20
+ except Exception:
21
+ return default
22
+
23
+
24
+ def _clamp(value: float, lo: float, hi: float) -> float:
25
+ return max(lo, min(hi, value))
26
+
27
+
28
+ def _reliability(sample_size: Any, k: float = 120.0) -> float:
29
+ sample = max(0.0, float(_safe_float(sample_size, 0.0) or 0.0))
30
+ return _clamp(sample / (sample + max(1.0, k)), 0.0, 1.0)
31
+
32
+
33
+ def _confidence_component(label: str, value: float, direction: str) -> dict[str, Any]:
34
+ return {
35
+ "label": label,
36
+ "value": round(float(value), 1),
37
+ "direction": direction,
38
+ }
39
+
40
+
41
+ def _poisson_prob_over(expected_value: float, line: float) -> float:
42
+ if expected_value <= 0:
43
+ return 0.0
44
+ target = int(math.floor(line))
45
+ cumulative = 0.0
46
+ for k in range(0, target + 1):
47
+ cumulative += math.exp(-expected_value) * (expected_value ** k) / math.factorial(k)
48
+ return _clamp(1.0 - cumulative, 0.0, 1.0)
49
+
50
+
51
+ def _poisson_prob_under(expected_value: float, line: float) -> float:
52
+ return _clamp(1.0 - _poisson_prob_over(expected_value, line), 0.0, 1.0)
53
+
54
+
55
+ def _calibrate(probability: float) -> float:
56
+ centered = probability - 0.50
57
+ return _clamp(0.50 + (centered * 0.92), 0.02, 0.98)
58
+
59
+
60
+ def build_strikeout_probability_result_v2(
61
+ pitcher_statcast_df: pd.DataFrame,
62
+ pitcher_name: str,
63
+ batter_statcast_df: pd.DataFrame | None = None,
64
+ opponent_batters: list[str] | None = None,
65
+ opponent_team: str | None = None,
66
+ line: float | None = None,
67
+ selection_side: str | None = None,
68
+ game_row: dict[str, Any] | None = None,
69
+ ) -> dict[str, Any]:
70
+ result: dict[str, Any] = {
71
+ "formula_version": "strikeout_v2_shadow",
72
+ "raw_k_prob_v2": None,
73
+ "calibrated_k_prob_v2": None,
74
+ "fair_prob_v2": None,
75
+ "expected_strikeouts_v2": None,
76
+ "projected_pitch_count": None,
77
+ "projected_batters_faced": None,
78
+ "projected_innings": None,
79
+ "projected_k_rate": None,
80
+ "k_rate_pitch_signal": None,
81
+ "k_rate_anchor": None,
82
+ "bb_rate_anchor": None,
83
+ "command_efficiency_signal": None,
84
+ "swing_miss_subscore": None,
85
+ "called_strike_subscore": None,
86
+ "command_efficiency_subscore": None,
87
+ "lineup_whiff_subscore": None,
88
+ "zone_matchup_subscore": None,
89
+ "family_zone_matchup_subscore": None,
90
+ "arsenal_fit_subscore": None,
91
+ "tunneling_subscore": None,
92
+ "release_consistency_subscore": None,
93
+ "sequencing_subscore": None,
94
+ "count_leverage_subscore": None,
95
+ "leash_risk_subscore": None,
96
+ "times_through_order_penalty": None,
97
+ "variance_band_low": None,
98
+ "variance_band_high": None,
99
+ "matchup_coverage_confidence": None,
100
+ "component_source_map": {},
101
+ "predicted_whiff_regions": [],
102
+ "predicted_attack_regions": [],
103
+ "predicted_damage_regions": [],
104
+ "tunnel_pair_scores": [],
105
+ "applied_layers_v2": "",
106
+ "skipped_layers_v2": "",
107
+ "confidence_score_v2": None,
108
+ "confidence_score_raw_v2": None,
109
+ "confidence_score_display_v2": None,
110
+ "confidence_source_v2": "strikeout_v2_shadow",
111
+ "confidence_bucket_v2": None,
112
+ "confidence_reasons_v2": [],
113
+ "confidence_component_bonuses_v2": [],
114
+ "confidence_component_penalties_v2": [],
115
+ "confidence_primary_driver_v2": None,
116
+ "confidence_summary_label_v2": None,
117
+ }
118
+
119
+ if (
120
+ pitcher_statcast_df is None
121
+ or pitcher_statcast_df.empty
122
+ or not pitcher_name
123
+ or line is None
124
+ or selection_side not in {"over", "under"}
125
+ ):
126
+ result["skipped_layers_v2"] = "missing_pitcher_or_line"
127
+ return result
128
+
129
+ pitcher_row = build_pitcher_feature_row(pitcher_statcast_df, pitcher_name)
130
+ sample_size = int(pitcher_row.get("sample_size") or 0)
131
+ reliability = _reliability(sample_size, k=180.0)
132
+ swstr = _safe_float(pitcher_row.get("swstr_rate"))
133
+ csw = _safe_float(pitcher_row.get("csw_rate"))
134
+ ball = _safe_float(pitcher_row.get("ball_rate"))
135
+
136
+ strike_anchor = None
137
+ walk_anchor = None
138
+ if swstr is not None:
139
+ strike_anchor = _clamp(0.12 + ((swstr - 0.11) * 1.15), 0.12, 0.42)
140
+ if ball is not None:
141
+ walk_anchor = _clamp(0.05 + ((ball - 0.36) * 0.75), 0.02, 0.14)
142
+
143
+ matchup_rows: list[dict[str, Any]] = []
144
+ if batter_statcast_df is not None and not batter_statcast_df.empty and opponent_batters:
145
+ for batter_name in opponent_batters[:9]:
146
+ matchup_rows.append(
147
+ compose_shared_matchup_context(
148
+ batter_name=batter_name,
149
+ pitcher_name=pitcher_name,
150
+ batter_statcast_df=batter_statcast_df,
151
+ pitcher_statcast_df=pitcher_statcast_df,
152
+ pitcher_row=pitcher_row,
153
+ game_row=game_row,
154
+ batter_features={"batter_stand": "L"},
155
+ )
156
+ )
157
+
158
+ if matchup_rows:
159
+ def _avg(path: tuple[str, ...], default: float = 0.0) -> float:
160
+ vals: list[float] = []
161
+ for row in matchup_rows:
162
+ cur: Any = row
163
+ for key in path:
164
+ if not isinstance(cur, dict):
165
+ cur = default
166
+ break
167
+ cur = cur.get(key)
168
+ if cur is not None:
169
+ vals.append(float(_safe_float(cur, default) or default))
170
+ return sum(vals) / len(vals) if vals else default
171
+
172
+ matchup = {
173
+ "predicted_whiff_regions": matchup_rows[0].get("predicted_whiff_regions") or [],
174
+ "predicted_attack_regions": matchup_rows[0].get("predicted_attack_regions") or [],
175
+ "predicted_damage_regions": matchup_rows[0].get("predicted_damage_regions") or [],
176
+ "tunnel_pair_scores": matchup_rows[0].get("tunnel_pair_scores") or [],
177
+ "matchup_coverage_confidence": _avg(("matchup_coverage_confidence",), 0.0),
178
+ "component_source_map": matchup_rows[0].get("component_source_map") or {},
179
+ "zone_matchup": {"hit_zone_boost": _avg(("zone_matchup", "hit_zone_boost"), 0.0)},
180
+ "family_zone_matchup": {"family_zone_whiff_risk": _avg(("family_zone_matchup", "family_zone_whiff_risk"), 0.0)},
181
+ "arsenal_matchup": {"arsenal_whiff_risk": _avg(("arsenal_matchup", "arsenal_whiff_risk"), 0.0)},
182
+ "trajectory": matchup_rows[0].get("trajectory") or {},
183
+ "count_context_profile": matchup_rows[0].get("count_context_profile") or {},
184
+ }
185
+ else:
186
+ matchup = {
187
+ "predicted_whiff_regions": [],
188
+ "predicted_attack_regions": [],
189
+ "predicted_damage_regions": [],
190
+ "tunnel_pair_scores": [],
191
+ "matchup_coverage_confidence": 0.0,
192
+ "component_source_map": {},
193
+ "zone_matchup": {},
194
+ "family_zone_matchup": {},
195
+ "arsenal_matchup": {},
196
+ "trajectory": {},
197
+ "count_context_profile": {},
198
+ }
199
+
200
+ zone_matchup_subscore = _safe_float((matchup.get("zone_matchup") or {}).get("hit_zone_boost"), 0.0) or 0.0
201
+ family_zone_matchup_subscore = _safe_float((matchup.get("family_zone_matchup") or {}).get("family_zone_whiff_risk"), 0.0) or 0.0
202
+ arsenal_fit_subscore = _safe_float((matchup.get("arsenal_matchup") or {}).get("arsenal_whiff_risk"), 0.0) or 0.0
203
+ trajectory = matchup.get("trajectory") or {}
204
+ tunneling_subscore = _safe_float(trajectory.get("tunnel_score"), 0.5) or 0.5
205
+ release_consistency_subscore = _safe_float(trajectory.get("release_consistency_score"), 0.5) or 0.5
206
+ sequencing_profiles = matchup.get("count_context_profile") or {}
207
+ putaway_states = [v for k, v in sequencing_profiles.items() if str(k).endswith("-2")]
208
+ count_leverage_subscore = 0.58 if putaway_states else 0.50
209
+ sequencing_subscore = _clamp(0.5 + ((count_leverage_subscore - 0.5) * 0.6), 0.0, 1.0)
210
+
211
+ swing_miss_subscore = _clamp((swstr or 0.11) / 0.18, 0.0, 1.0)
212
+ called_strike_subscore = _clamp((csw or 0.28) / 0.36, 0.0, 1.0)
213
+ command_efficiency_signal = _clamp(1.0 - ((ball or 0.36) - 0.30) / 0.12, 0.0, 1.0)
214
+ command_efficiency_subscore = command_efficiency_signal
215
+ lineup_whiff_subscore = _clamp(
216
+ (
217
+ family_zone_matchup_subscore * 0.55
218
+ + arsenal_fit_subscore * 0.45
219
+ ) / 0.35 if (family_zone_matchup_subscore or arsenal_fit_subscore) else 0.5,
220
+ 0.0,
221
+ 1.0,
222
+ )
223
+
224
+ # Opportunity layer: explicit but conservative until richer workload data is added.
225
+ pitch_count = 88.0
226
+ if swstr is not None:
227
+ pitch_count += (swstr - 0.11) * 18.0
228
+ if csw is not None:
229
+ pitch_count += (csw - 0.28) * 12.0
230
+ if ball is not None:
231
+ pitch_count -= (ball - 0.36) * 28.0
232
+ if opponent_batters and len(opponent_batters) >= 7:
233
+ pitch_count += 1.5
234
+ leash_risk_score = _clamp(1.0 - command_efficiency_signal, 0.0, 1.0)
235
+ pitch_count -= leash_risk_score * 8.0
236
+ pitch_count = _clamp(pitch_count, 64.0, 108.0)
237
+
238
+ pitches_per_bf = 3.85
239
+ if ball is not None:
240
+ pitches_per_bf += (ball - 0.36) * 2.4
241
+ projected_batters_faced = _clamp(pitch_count / max(3.1, pitches_per_bf), 16.0, 31.0)
242
+ projected_innings = _clamp(projected_batters_faced / 4.35, 3.8, 7.8)
243
+ times_through_order_penalty = _clamp(max(0.0, projected_batters_faced - 24.0) * 0.004, 0.0, 0.06)
244
+
245
+ pitch_signal = (
246
+ swing_miss_subscore * 0.34
247
+ + called_strike_subscore * 0.18
248
+ + command_efficiency_subscore * 0.14
249
+ + lineup_whiff_subscore * 0.10
250
+ + _clamp(zone_matchup_subscore / 0.40, 0.0, 1.0) * 0.08
251
+ + _clamp(family_zone_matchup_subscore / 0.40, 0.0, 1.0) * 0.06
252
+ + _clamp(arsenal_fit_subscore / 0.35, 0.0, 1.0) * 0.05
253
+ + tunneling_subscore * 0.03
254
+ + release_consistency_subscore * 0.01
255
+ + sequencing_subscore * 0.01
256
+ )
257
+ k_rate_pitch_signal = _clamp(0.12 + (pitch_signal * 0.26), 0.14, 0.42)
258
+ k_rate_anchor = strike_anchor if strike_anchor is not None else k_rate_pitch_signal
259
+ projected_k_rate = (
260
+ k_rate_pitch_signal * (1.0 - reliability)
261
+ + k_rate_anchor * reliability
262
+ )
263
+ projected_k_rate = _clamp(projected_k_rate - times_through_order_penalty, 0.12, 0.40)
264
+
265
+ expected_strikeouts = _clamp(projected_batters_faced * projected_k_rate, 1.5, 11.5)
266
+ raw_prob = _poisson_prob_over(expected_strikeouts, float(line)) if selection_side == "over" else _poisson_prob_under(expected_strikeouts, float(line))
267
+ calibrated_prob = _calibrate(raw_prob)
268
+ variance = _clamp(0.35 + leash_risk_score * 0.9 + (1.0 - reliability) * 0.8, 0.35, 1.8)
269
+
270
+ confidence = 56.0
271
+ reasons: list[str] = []
272
+ bonuses: list[dict[str, Any]] = []
273
+ penalties: list[dict[str, Any]] = []
274
+ if sample_size >= 400:
275
+ confidence += 10.0
276
+ bonuses.append(_confidence_component("Strong pitcher sample", 10.0, "bonus"))
277
+ elif sample_size < 150:
278
+ confidence -= 10.0
279
+ reasons.append("Limited pitcher pitch sample")
280
+ penalties.append(_confidence_component("Limited pitcher pitch sample", 10.0, "penalty"))
281
+ if matchup.get("matchup_coverage_confidence", 0.0) >= 0.45:
282
+ confidence += 8.0
283
+ bonuses.append(_confidence_component("Strong telemetry and zone coverage", 8.0, "bonus"))
284
+ else:
285
+ confidence -= 6.0
286
+ reasons.append("Thin telemetry and zone-coverage sample")
287
+ penalties.append(_confidence_component("Thin telemetry and zone-coverage sample", 6.0, "penalty"))
288
+ if opponent_batters and len(opponent_batters) >= 7:
289
+ confidence += 5.0
290
+ bonuses.append(_confidence_component("Projected lineup mostly complete", 5.0, "bonus"))
291
+ else:
292
+ confidence -= 5.0
293
+ reasons.append("Projected opponent lineup is incomplete")
294
+ penalties.append(_confidence_component("Projected opponent lineup is incomplete", 5.0, "penalty"))
295
+ if leash_risk_score >= 0.55:
296
+ confidence -= 7.0
297
+ reasons.append("Pitch-count and leash risk remain elevated")
298
+ penalties.append(_confidence_component("Pitch-count and leash risk remain elevated", 7.0, "penalty"))
299
+
300
+ confidence_raw = _clamp(confidence, 1.0, 100.0)
301
+ bucket = "high" if confidence_raw >= 75 else "medium" if confidence_raw >= 55 else "low"
302
+ primary_penalty = max(
303
+ [item for item in penalties if float(item.get("value") or 0.0) > 0.0],
304
+ key=lambda item: float(item.get("value") or 0.0),
305
+ default=None,
306
+ )
307
+ primary_bonus = max(
308
+ [item for item in bonuses if float(item.get("value") or 0.0) > 0.0],
309
+ key=lambda item: float(item.get("value") or 0.0),
310
+ default=None,
311
+ )
312
+ primary_driver = primary_penalty or primary_bonus
313
+ summary_label = str((primary_driver or {}).get("label") or "").strip() or None
314
+
315
+ result.update(
316
+ {
317
+ "raw_k_prob_v2": raw_prob,
318
+ "calibrated_k_prob_v2": calibrated_prob,
319
+ "fair_prob_v2": calibrated_prob,
320
+ "expected_strikeouts_v2": expected_strikeouts,
321
+ "projected_pitch_count": round(pitch_count, 2),
322
+ "projected_batters_faced": round(projected_batters_faced, 2),
323
+ "projected_innings": round(projected_innings, 2),
324
+ "projected_k_rate": round(projected_k_rate, 4),
325
+ "k_rate_pitch_signal": round(k_rate_pitch_signal, 4),
326
+ "k_rate_anchor": round(k_rate_anchor, 4) if k_rate_anchor is not None else None,
327
+ "bb_rate_anchor": round(walk_anchor, 4) if walk_anchor is not None else None,
328
+ "command_efficiency_signal": round(command_efficiency_signal, 4),
329
+ "swing_miss_subscore": round(swing_miss_subscore, 4),
330
+ "called_strike_subscore": round(called_strike_subscore, 4),
331
+ "command_efficiency_subscore": round(command_efficiency_subscore, 4),
332
+ "lineup_whiff_subscore": round(lineup_whiff_subscore, 4),
333
+ "zone_matchup_subscore": round(zone_matchup_subscore, 4),
334
+ "family_zone_matchup_subscore": round(family_zone_matchup_subscore, 4),
335
+ "arsenal_fit_subscore": round(arsenal_fit_subscore, 4),
336
+ "tunneling_subscore": round(tunneling_subscore, 4),
337
+ "release_consistency_subscore": round(release_consistency_subscore, 4),
338
+ "sequencing_subscore": round(sequencing_subscore, 4),
339
+ "count_leverage_subscore": round(count_leverage_subscore, 4),
340
+ "leash_risk_subscore": round(leash_risk_score, 4),
341
+ "times_through_order_penalty": round(times_through_order_penalty, 4),
342
+ "variance_band_low": round(_clamp(expected_strikeouts - variance, 0.5, 12.0), 2),
343
+ "variance_band_high": round(_clamp(expected_strikeouts + variance, 0.5, 12.5), 2),
344
+ "matchup_coverage_confidence": matchup.get("matchup_coverage_confidence"),
345
+ "component_source_map": matchup.get("component_source_map") or {},
346
+ "predicted_whiff_regions": matchup.get("predicted_whiff_regions") or [],
347
+ "predicted_attack_regions": matchup.get("predicted_attack_regions") or [],
348
+ "predicted_damage_regions": matchup.get("predicted_damage_regions") or [],
349
+ "tunnel_pair_scores": matchup.get("tunnel_pair_scores") or [],
350
+ "applied_layers_v2": "opportunity|pitch_win|probability|uncertainty",
351
+ "skipped_layers_v2": "",
352
+ "confidence_score_v2": round(confidence_raw, 1),
353
+ "confidence_score_raw_v2": round(confidence_raw, 1),
354
+ "confidence_score_display_v2": round(confidence_raw, 1),
355
+ "confidence_bucket_v2": bucket,
356
+ "confidence_reasons_v2": reasons[:5],
357
+ "confidence_component_bonuses_v2": bonuses,
358
+ "confidence_component_penalties_v2": penalties,
359
+ "confidence_primary_driver_v2": primary_driver,
360
+ "confidence_summary_label_v2": summary_label,
361
+ }
362
+ )
363
+ return result
tests/test_props_mapper.py CHANGED
@@ -663,8 +663,11 @@ class TestPropsMapper(unittest.TestCase):
663
  "fair_prob": 0.58,
664
  "expected_strikeouts": 7.2,
665
  "confidence_score": 72.0,
 
666
  "confidence_bucket": "medium",
667
  "confidence_reasons": ["Projected opponent lineup is incomplete"],
 
 
668
  "pitcher_swstr_rate": 0.14,
669
  "pitcher_csw_rate": 0.31,
670
  "pitcher_ball_rate": 0.34,
@@ -679,6 +682,54 @@ class TestPropsMapper(unittest.TestCase):
679
  "reason_tags_for": ["Misses bats consistently", "Strong pitch tunneling"],
680
  "reason_tags_against": ["Projected opponent lineup is incomplete"],
681
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  ):
683
  result = map_props_to_models(
684
  props_df,
@@ -693,6 +744,11 @@ class TestPropsMapper(unittest.TestCase):
693
  self.assertEqual(row["selection_scope"], "pitcher")
694
  self.assertIn(row["verdict"], {"bet", "watch", "pass"})
695
  self.assertTrue(bool(str(row["model_voice_for"])))
 
 
 
 
 
696
 
697
 
698
  if __name__ == "__main__":
 
663
  "fair_prob": 0.58,
664
  "expected_strikeouts": 7.2,
665
  "confidence_score": 72.0,
666
+ "confidence_score_raw": 72.0,
667
  "confidence_bucket": "medium",
668
  "confidence_reasons": ["Projected opponent lineup is incomplete"],
669
+ "confidence_component_bonuses": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}],
670
+ "confidence_component_penalties": [{"label": "Projected opponent lineup is incomplete", "value": 6, "direction": "penalty"}],
671
  "pitcher_swstr_rate": 0.14,
672
  "pitcher_csw_rate": 0.31,
673
  "pitcher_ball_rate": 0.34,
 
682
  "reason_tags_for": ["Misses bats consistently", "Strong pitch tunneling"],
683
  "reason_tags_against": ["Projected opponent lineup is incomplete"],
684
  },
685
+ ), patch(
686
+ "analytics.props_mapper.build_strikeout_probability_result_v2",
687
+ return_value={
688
+ "formula_version": "strikeout_v2_shadow",
689
+ "fair_prob_v2": 0.61,
690
+ "expected_strikeouts_v2": 7.0,
691
+ "projected_pitch_count": 94.0,
692
+ "projected_batters_faced": 25.0,
693
+ "projected_innings": 5.7,
694
+ "projected_k_rate": 0.28,
695
+ "raw_k_prob_v2": 0.61,
696
+ "calibrated_k_prob_v2": 0.61,
697
+ "confidence_score_v2": 64.0,
698
+ "confidence_score_raw_v2": 64.0,
699
+ "confidence_score_display_v2": 64.0,
700
+ "confidence_source_v2": "strikeout_v2_shadow",
701
+ "confidence_bucket_v2": "medium",
702
+ "confidence_reasons_v2": ["Projected opponent lineup is incomplete"],
703
+ "confidence_component_bonuses_v2": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}],
704
+ "confidence_component_penalties_v2": [{"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}],
705
+ "confidence_primary_driver_v2": {"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"},
706
+ "confidence_summary_label_v2": "Projected opponent lineup is incomplete",
707
+ "k_rate_pitch_signal": 0.28,
708
+ "k_rate_anchor": 0.27,
709
+ "bb_rate_anchor": 0.06,
710
+ "command_efficiency_signal": 0.69,
711
+ "swing_miss_subscore": 0.77,
712
+ "called_strike_subscore": 0.73,
713
+ "command_efficiency_subscore": 0.69,
714
+ "lineup_whiff_subscore": 0.58,
715
+ "zone_matchup_subscore": 0.19,
716
+ "family_zone_matchup_subscore": 0.22,
717
+ "arsenal_fit_subscore": 0.24,
718
+ "tunneling_subscore": 0.64,
719
+ "release_consistency_subscore": 0.62,
720
+ "sequencing_subscore": 0.66,
721
+ "count_leverage_subscore": 0.58,
722
+ "leash_risk_subscore": 0.34,
723
+ "times_through_order_penalty": 0.01,
724
+ "variance_band_low": 6.0,
725
+ "variance_band_high": 8.0,
726
+ "matchup_coverage_confidence": 0.48,
727
+ "component_source_map": {"shared_composer": "ok"},
728
+ "predicted_whiff_regions": ["shadow"],
729
+ "predicted_attack_regions": ["shadow"],
730
+ "predicted_damage_regions": ["heart"],
731
+ "tunnel_pair_scores": [{"pair": "FF/CH", "score": 0.62}],
732
+ },
733
  ):
734
  result = map_props_to_models(
735
  props_df,
 
744
  self.assertEqual(row["selection_scope"], "pitcher")
745
  self.assertIn(row["verdict"], {"bet", "watch", "pass"})
746
  self.assertTrue(bool(str(row["model_voice_for"])))
747
+ self.assertEqual(row["confidence_source"], "strikeout_v2_shadow")
748
+ self.assertGreater(float(row["confidence_score"]), 64.0)
749
+ self.assertEqual(row["confidence_summary_label"], "Projected opponent lineup is incomplete")
750
+ self.assertTrue(bool(row["confidence_component_bonuses"]))
751
+ self.assertTrue(bool(row["confidence_component_penalties"]))
752
 
753
 
754
  if __name__ == "__main__":
tests/test_shared_matchup_engine.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ from models.shared_matchup_engine import compose_shared_matchup_context
4
+
5
+
6
+ def test_compose_shared_matchup_context_empty_inputs():
7
+ result = compose_shared_matchup_context(
8
+ batter_name="",
9
+ pitcher_name="",
10
+ batter_statcast_df=pd.DataFrame(),
11
+ pitcher_statcast_df=pd.DataFrame(),
12
+ )
13
+
14
+ assert result["expected_pitch_mix_by_count"] == {}
15
+ assert result["predicted_attack_regions"] == []
16
+ assert "shared_composer" in result["component_source_map"]
17
+
tests/test_strikeout_probability_engine_v2.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ from models.strikeout_probability_engine_v2 import build_strikeout_probability_result_v2
4
+
5
+
6
+ def test_strikeout_probability_result_v2_missing_inputs():
7
+ result = build_strikeout_probability_result_v2(
8
+ pitcher_statcast_df=pd.DataFrame(),
9
+ pitcher_name="",
10
+ line=None,
11
+ selection_side=None,
12
+ )
13
+
14
+ assert result["formula_version"] == "strikeout_v2_shadow"
15
+ assert result["expected_strikeouts_v2"] is None
16
+ assert result["skipped_layers_v2"] == "missing_pitcher_or_line"
17
+ assert result["confidence_score_v2"] is None
18
+ assert result["confidence_component_bonuses_v2"] == []
19
+ assert result["confidence_component_penalties_v2"] == []
visualization/debug_page.py CHANGED
@@ -110,6 +110,142 @@ _LADDER_TB2P_FIELDS = [
110
  ("Final (simulated)", "tb2p_prob"),
111
  ]
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  # ---------------------------------------------------------------------------
115
  # Private diagnostic helpers
@@ -887,6 +1023,83 @@ def render_debug(
887
  hide_index=True,
888
  )
889
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
  with st.expander("Shared Baseline Diagnostics", expanded=False):
891
  baseline_summary_frames: list[pd.DataFrame] = []
892
  batter_meta = (baseline_bundle or {}).get("batter_baseline_meta", pd.DataFrame())
@@ -1079,6 +1292,46 @@ def render_debug(
1079
  else:
1080
  st.info("Open the Props page in this session to capture HR health diagnostics.")
1081
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1082
  with st.expander("Debug Event Row Read Status", expanded=False):
1083
  read_status = debug_event_row_status or {}
1084
  if read_status:
 
110
  ("Final (simulated)", "tb2p_prob"),
111
  ]
112
 
113
+ _MODEL_RUBRIC_WEIGHTS = {
114
+ "shared_telemetry": 18,
115
+ "explicit_opportunity": 16,
116
+ "explicit_components": 18,
117
+ "pitch_level_backbone": 12,
118
+ "hr_damage_modeling": 12,
119
+ "k_shadow_v2": 10,
120
+ "provenance_debug": 8,
121
+ "uncertainty_outputs": 6,
122
+ }
123
+
124
+
125
+ def _build_model_upgrade_rubric(
126
+ props_hr_health_debug: dict[str, Any] | None,
127
+ shared_component_debug: dict[str, Any] | None,
128
+ ) -> tuple[pd.DataFrame, dict[str, Any]]:
129
+ shared_component_debug = shared_component_debug or {}
130
+ props_hr_health_debug = props_hr_health_debug or {}
131
+ rows = pd.DataFrame(shared_component_debug.get("rows") or [])
132
+
133
+ has_shared = not rows.empty
134
+ has_hr_components = has_shared and any(
135
+ col in rows.columns
136
+ for col in [
137
+ "damage_zone_alignment_subscore",
138
+ "pitch_mix_exposure_subscore",
139
+ "tunnel_damage_subscore",
140
+ "count_pattern_damage_subscore",
141
+ ]
142
+ )
143
+ has_k_v2 = has_shared and "expected_strikeouts_v2" in rows.columns
144
+ has_opportunity = has_shared and any(
145
+ col in rows.columns
146
+ for col in ["projected_pitch_count", "projected_batters_faced", "projected_innings"]
147
+ )
148
+ has_uncertainty = has_shared and any(
149
+ col in rows.columns
150
+ for col in ["variance_band_low", "variance_band_high", "matchup_coverage_confidence"]
151
+ )
152
+ has_provenance = has_shared and "component_source_map" in rows.columns
153
+ has_pitch_backbone = has_shared and any(
154
+ col in rows.columns
155
+ for col in [
156
+ "zone_matchup_subscore",
157
+ "family_zone_matchup_subscore",
158
+ "arsenal_fit_subscore",
159
+ "tunneling_subscore",
160
+ "sequencing_subscore",
161
+ ]
162
+ )
163
+ has_explicit_components = has_shared and any(
164
+ col in rows.columns
165
+ for col in [
166
+ "damage_zone_alignment_subscore",
167
+ "arsenal_fit_subscore",
168
+ "zone_matchup_subscore",
169
+ "count_leverage_subscore",
170
+ ]
171
+ )
172
+
173
+ grade_rows = [
174
+ {
175
+ "category": "Shared telemetry framework",
176
+ "weight": _MODEL_RUBRIC_WEIGHTS["shared_telemetry"],
177
+ "old_system": 2,
178
+ "current_system": _MODEL_RUBRIC_WEIGHTS["shared_telemetry"] if has_shared else 0,
179
+ "status": "active" if has_shared else "missing",
180
+ "evidence": "shared_matchup_engine + shared diagnostics" if has_shared else "not captured yet",
181
+ },
182
+ {
183
+ "category": "Explicit opportunity modeling",
184
+ "weight": _MODEL_RUBRIC_WEIGHTS["explicit_opportunity"],
185
+ "old_system": 3,
186
+ "current_system": _MODEL_RUBRIC_WEIGHTS["explicit_opportunity"] if has_opportunity else 0,
187
+ "status": "active" if has_opportunity else "missing",
188
+ "evidence": "projected pitch count / BF / innings" if has_opportunity else "old heuristic only",
189
+ },
190
+ {
191
+ "category": "Explicit modeled components",
192
+ "weight": _MODEL_RUBRIC_WEIGHTS["explicit_components"],
193
+ "old_system": 8,
194
+ "current_system": _MODEL_RUBRIC_WEIGHTS["explicit_components"] if has_explicit_components else 0,
195
+ "status": "active" if has_explicit_components else "partial",
196
+ "evidence": "named subscores emitted" if has_explicit_components else "implicit heuristics",
197
+ },
198
+ {
199
+ "category": "Pitch-level backbone",
200
+ "weight": _MODEL_RUBRIC_WEIGHTS["pitch_level_backbone"],
201
+ "old_system": 8,
202
+ "current_system": _MODEL_RUBRIC_WEIGHTS["pitch_level_backbone"] if has_pitch_backbone else 0,
203
+ "status": "active" if has_pitch_backbone else "missing",
204
+ "evidence": "zone/family-zone/arsenal/tunnel/sequencing present" if has_pitch_backbone else "not surfaced",
205
+ },
206
+ {
207
+ "category": "HR damage-zone modeling",
208
+ "weight": _MODEL_RUBRIC_WEIGHTS["hr_damage_modeling"],
209
+ "old_system": 8,
210
+ "current_system": _MODEL_RUBRIC_WEIGHTS["hr_damage_modeling"] if has_hr_components else 0,
211
+ "status": "active" if has_hr_components else "missing",
212
+ "evidence": "damage-zone and pitch-mix exposure subscores" if has_hr_components else "legacy HR layers only",
213
+ },
214
+ {
215
+ "category": "K shadow v2 readiness",
216
+ "weight": _MODEL_RUBRIC_WEIGHTS["k_shadow_v2"],
217
+ "old_system": 0,
218
+ "current_system": _MODEL_RUBRIC_WEIGHTS["k_shadow_v2"] if has_k_v2 else 0,
219
+ "status": "active" if has_k_v2 else "missing",
220
+ "evidence": "strikeout v2 outputs captured" if has_k_v2 else "current K engine only",
221
+ },
222
+ {
223
+ "category": "Provenance and debug traceability",
224
+ "weight": _MODEL_RUBRIC_WEIGHTS["provenance_debug"],
225
+ "old_system": 4,
226
+ "current_system": _MODEL_RUBRIC_WEIGHTS["provenance_debug"] if has_provenance else 0,
227
+ "status": "active" if has_provenance else "missing",
228
+ "evidence": "component_source_map exposed" if has_provenance else "limited traceability",
229
+ },
230
+ {
231
+ "category": "Uncertainty outputs",
232
+ "weight": _MODEL_RUBRIC_WEIGHTS["uncertainty_outputs"],
233
+ "old_system": 3,
234
+ "current_system": _MODEL_RUBRIC_WEIGHTS["uncertainty_outputs"] if has_uncertainty else 0,
235
+ "status": "active" if has_uncertainty else "missing",
236
+ "evidence": "variance bands / coverage confidence" if has_uncertainty else "point estimate only",
237
+ },
238
+ ]
239
+
240
+ rubric_df = pd.DataFrame(grade_rows)
241
+ summary = {
242
+ "old_architecture_score": int(rubric_df["old_system"].sum()),
243
+ "current_architecture_score": int(rubric_df["current_system"].sum()),
244
+ "max_score": int(rubric_df["weight"].sum()),
245
+ "modeled_hr_rows_total": int(props_hr_health_debug.get("modeled_hr_rows_total") or 0),
246
+ }
247
+ return rubric_df, summary
248
+
249
 
250
  # ---------------------------------------------------------------------------
251
  # Private diagnostic helpers
 
1023
  hide_index=True,
1024
  )
1025
 
1026
+ with st.expander("Strikeout Confidence Diagnostics", expanded=False):
1027
+ strikeout_df = exec_df[
1028
+ exec_df.get("market_family", pd.Series(index=exec_df.index, dtype="object"))
1029
+ .astype(str)
1030
+ .str.lower()
1031
+ .eq("k")
1032
+ ].copy()
1033
+ if strikeout_df.empty:
1034
+ st.info("No strikeout props are currently available.")
1035
+ else:
1036
+ summary_cols = [
1037
+ "player_name_raw",
1038
+ "sportsbook",
1039
+ "display_label",
1040
+ "selection_side",
1041
+ "fair_prob",
1042
+ "confidence_score",
1043
+ "confidence_score_raw",
1044
+ "confidence_score_display",
1045
+ "confidence_source",
1046
+ "confidence_bucket",
1047
+ "confidence_bucket_raw",
1048
+ "confidence_bucket_display",
1049
+ "confidence_summary_label",
1050
+ "confidence_reasons",
1051
+ "confidence_score_v1",
1052
+ "confidence_bucket_v1",
1053
+ "confidence_score_v2",
1054
+ "confidence_score_raw_v2",
1055
+ "confidence_score_display_v2",
1056
+ "confidence_bucket_v2",
1057
+ "confidence_reasons_v2",
1058
+ "projected_pitch_count",
1059
+ "projected_batters_faced",
1060
+ "projected_innings",
1061
+ "expected_strikeouts",
1062
+ "expected_strikeouts_v2",
1063
+ ]
1064
+ st.write("Card-facing strikeout confidence rows")
1065
+ st.dataframe(
1066
+ strikeout_df[[c for c in summary_cols if c in strikeout_df.columns]],
1067
+ use_container_width=True,
1068
+ hide_index=True,
1069
+ )
1070
+
1071
+ component_rows: list[dict[str, Any]] = []
1072
+ for _, row in strikeout_df.iterrows():
1073
+ player_name = row.get("player_name_raw") or row.get("player_name")
1074
+ display_label = row.get("display_label")
1075
+ for item in row.get("confidence_component_bonuses") or []:
1076
+ component_rows.append(
1077
+ {
1078
+ "player_name": player_name,
1079
+ "display_label": display_label,
1080
+ "component_type": "bonus",
1081
+ "label": item.get("label"),
1082
+ "value": item.get("value"),
1083
+ "source": row.get("confidence_source"),
1084
+ }
1085
+ )
1086
+ for item in row.get("confidence_component_penalties") or []:
1087
+ component_rows.append(
1088
+ {
1089
+ "player_name": player_name,
1090
+ "display_label": display_label,
1091
+ "component_type": "penalty",
1092
+ "label": item.get("label"),
1093
+ "value": item.get("value"),
1094
+ "source": row.get("confidence_source"),
1095
+ }
1096
+ )
1097
+ if component_rows:
1098
+ st.write("Confidence component math")
1099
+ st.dataframe(pd.DataFrame(component_rows), use_container_width=True, hide_index=True)
1100
+ else:
1101
+ st.info("No confidence component rows are present yet.")
1102
+
1103
  with st.expander("Shared Baseline Diagnostics", expanded=False):
1104
  baseline_summary_frames: list[pd.DataFrame] = []
1105
  batter_meta = (baseline_bundle or {}).get("batter_baseline_meta", pd.DataFrame())
 
1292
  else:
1293
  st.info("Open the Props page in this session to capture HR health diagnostics.")
1294
 
1295
+ with st.expander("Shared Matchup Component Diagnostics", expanded=False):
1296
+ shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
1297
+ if shared_component_debug:
1298
+ st.caption(
1299
+ f"Captured from Props market: {str(shared_component_debug.get('market_type') or 'unknown').upper()}"
1300
+ )
1301
+ rows_df = pd.DataFrame(shared_component_debug.get("rows") or [])
1302
+ if not rows_df.empty:
1303
+ st.dataframe(rows_df, use_container_width=True, hide_index=True)
1304
+ else:
1305
+ st.info("No shared-component rows captured in this session.")
1306
+ else:
1307
+ st.info("Open the Props page in this session to capture shared matchup diagnostics.")
1308
+
1309
+ with st.expander("Model Grading Rubric", expanded=False):
1310
+ props_hr_health_debug = st.session_state.get("props_hr_health_debug") or {}
1311
+ shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
1312
+ rubric_df, rubric_summary = _build_model_upgrade_rubric(
1313
+ props_hr_health_debug=props_hr_health_debug,
1314
+ shared_component_debug=shared_component_debug,
1315
+ )
1316
+ c1, c2, c3 = st.columns(3)
1317
+ c1.metric(
1318
+ "Old Architecture Grade",
1319
+ f"{int(rubric_summary.get('old_architecture_score') or 0)}/{int(rubric_summary.get('max_score') or 100)}",
1320
+ )
1321
+ c2.metric(
1322
+ "Current Architecture Grade",
1323
+ f"{int(rubric_summary.get('current_architecture_score') or 0)}/{int(rubric_summary.get('max_score') or 100)}",
1324
+ )
1325
+ c3.metric(
1326
+ "Modeled 1+ HR Rows",
1327
+ int(rubric_summary.get("modeled_hr_rows_total") or 0),
1328
+ )
1329
+ st.caption(
1330
+ "This is an architecture and model-readiness rubric, not a live ROI or hit-rate grade. "
1331
+ "Replace or augment it with rolling backtest metrics as the evaluation layer is built."
1332
+ )
1333
+ st.dataframe(rubric_df, use_container_width=True, hide_index=True)
1334
+
1335
  with st.expander("Debug Event Row Read Status", expanded=False):
1336
  read_status = debug_event_row_status or {}
1337
  if read_status:
visualization/props_page.py CHANGED
@@ -223,6 +223,13 @@ def _render_props_ui_styles() -> None:
223
  .props-metric-value.bad {
224
  color: #ff7f7f;
225
  }
 
 
 
 
 
 
 
226
  .props-verdict {
227
  display: inline-block;
228
  border-radius: 999px;
@@ -341,6 +348,71 @@ def _format_confidence(val: float | None) -> str:
341
  return "-"
342
 
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  def _market_label(value: Any) -> str:
345
  text = str(value or "").strip().lower()
346
  labels = {
@@ -638,6 +710,10 @@ def _build_market_modeling_payload(
638
  )
639
  else:
640
  st.session_state.pop("props_hr_health_debug", None)
 
 
 
 
641
 
642
  return {
643
  "baseline_request": baseline_request,
@@ -1038,6 +1114,51 @@ def _build_hr_health_debug(display: pd.DataFrame, extra_context: dict[str, Any]
1038
  return out
1039
 
1040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1041
  def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | None = None) -> None:
1042
  st.markdown(
1043
  """
@@ -1136,7 +1257,7 @@ def render_featured_hr_cards(featured_df: pd.DataFrame) -> None:
1136
  </div>
1137
  <div>
1138
  <div class="props-metric-label">Confidence</div>
1139
- <div class="props-metric-value">{_format_confidence(row.get('confidence_score'))}</div>
1140
  </div>
1141
  </div>
1142
  <div class="props-voice">
@@ -1216,7 +1337,7 @@ def render_best_on_slate_cards(best_df: pd.DataFrame, summary: dict[str, Any] |
1216
  </div>
1217
  <div>
1218
  <div class="props-metric-label">Confidence</div>
1219
- <div class="props-metric-value">{_format_confidence(row.get('confidence_score'))}</div>
1220
  </div>
1221
  </div>
1222
  <div class="props-voice">
@@ -1291,8 +1412,15 @@ def render_player_hr_details(player_details: dict[str, Any]) -> None:
1291
  "model_voice_for",
1292
  "model_voice_against",
1293
  "confidence_score",
 
 
 
1294
  "confidence_bucket",
1295
  "confidence_reasons",
 
 
 
 
1296
  "opportunity_hr_adjustment",
1297
  "expected_pa",
1298
  "lineup_slot_used",
@@ -1380,6 +1508,7 @@ def render_player_hr_row(player_entry: dict[str, Any]) -> None:
1380
  st.caption("Why this rating")
1381
  for line in why_lines:
1382
  st.write(f"- {line}")
 
1383
  render_player_hr_details(details)
1384
  st.divider()
1385
 
 
223
  .props-metric-value.bad {
224
  color: #ff7f7f;
225
  }
226
+ .props-metric-subvalue {
227
+ color: #88a0bb;
228
+ font-size: 0.72rem;
229
+ line-height: 1.25;
230
+ margin-top: 0.12rem;
231
+ min-height: 1rem;
232
+ }
233
  .props-verdict {
234
  display: inline-block;
235
  border-radius: 999px;
 
348
  return "-"
349
 
350
 
351
+ def _confidence_summary_label(value: Any) -> str:
352
+ text = str(value or "").strip()
353
+ return text if text else "-"
354
+
355
+
356
+ def _build_confidence_metric_html(row: pd.Series | dict[str, Any]) -> str:
357
+ score = _format_confidence(row.get("confidence_score"))
358
+ summary = _confidence_summary_label(row.get("confidence_summary_label"))
359
+ if summary == "-":
360
+ summary = "&nbsp;"
361
+ return (
362
+ f"<div class=\"props-metric-value\">{score}</div>"
363
+ f"<div class=\"props-metric-subvalue\">{summary}</div>"
364
+ )
365
+
366
+
367
+ def _render_confidence_breakdown(details_row: dict[str, Any]) -> None:
368
+ raw_score = details_row.get("confidence_score_raw")
369
+ display_score = details_row.get("confidence_score_display", details_row.get("confidence_score"))
370
+ confidence_source = str(details_row.get("confidence_source") or "").strip() or "-"
371
+ final_bucket = str(details_row.get("confidence_bucket") or details_row.get("confidence_bucket_display") or "-").strip()
372
+ summary_label = str(details_row.get("confidence_summary_label") or "").strip()
373
+ bonuses = details_row.get("confidence_component_bonuses") or []
374
+ penalties = details_row.get("confidence_component_penalties") or []
375
+
376
+ if (
377
+ raw_score is None
378
+ and display_score is None
379
+ and confidence_source == "-"
380
+ and not bonuses
381
+ and not penalties
382
+ ):
383
+ return
384
+
385
+ st.caption("Confidence Breakdown")
386
+ metric_cols = st.columns(4)
387
+ metric_cols[0].metric("Raw/Base", _format_confidence(raw_score))
388
+ metric_cols[1].metric("Display", _format_confidence(display_score))
389
+ metric_cols[2].metric("Source", confidence_source.replace("_", " ").title())
390
+ metric_cols[3].metric("Bucket", final_bucket.title() if final_bucket else "-")
391
+
392
+ if bonuses:
393
+ st.write("Bonuses")
394
+ for bonus in bonuses:
395
+ st.write(f"- +{float(bonus.get('value') or 0.0):.0f} {str(bonus.get('label') or '-').strip()}")
396
+
397
+ if penalties:
398
+ st.write("Penalties")
399
+ for penalty in penalties:
400
+ value = float(penalty.get("value") or 0.0)
401
+ prefix = f"-{value:.0f}" if value > 0 else "0"
402
+ st.write(f"- {prefix} {str(penalty.get('label') or '-').strip()}")
403
+
404
+ why_lines: list[str] = []
405
+ if summary_label:
406
+ why_lines.append(f"Primary driver: {summary_label}")
407
+ for reason in details_row.get("confidence_reasons") or []:
408
+ if str(reason).strip():
409
+ why_lines.append(str(reason).strip())
410
+ if why_lines:
411
+ st.write("Why It Landed Here")
412
+ for line in why_lines:
413
+ st.write(f"- {line}")
414
+
415
+
416
  def _market_label(value: Any) -> str:
417
  text = str(value or "").strip().lower()
418
  labels = {
 
710
  )
711
  else:
712
  st.session_state.pop("props_hr_health_debug", None)
713
+ st.session_state["props_shared_component_debug"] = _build_shared_component_debug(
714
+ mapped,
715
+ market_type=market_type,
716
+ )
717
 
718
  return {
719
  "baseline_request": baseline_request,
 
1114
  return out
1115
 
1116
 
1117
+ def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> dict[str, Any]:
1118
+ if display is None or display.empty:
1119
+ return {"market_type": market_type, "rows": []}
1120
+
1121
+ cols = [
1122
+ "player_name",
1123
+ "player_name_raw",
1124
+ "display_label",
1125
+ "resolved_pitcher_name",
1126
+ "formula_version",
1127
+ "matchup_coverage_confidence",
1128
+ "damage_zone_alignment_subscore",
1129
+ "pitch_mix_exposure_subscore",
1130
+ "tunnel_damage_subscore",
1131
+ "count_pattern_damage_subscore",
1132
+ "handedness_damage_subscore",
1133
+ "arsenal_fit_subscore",
1134
+ "environment_amplification_subscore",
1135
+ "hr_opportunity_projection",
1136
+ "projected_pitch_count",
1137
+ "projected_batters_faced",
1138
+ "projected_innings",
1139
+ "projected_k_rate",
1140
+ "expected_strikeouts_v2",
1141
+ "zone_matchup_subscore",
1142
+ "family_zone_matchup_subscore",
1143
+ "tunneling_subscore",
1144
+ "release_consistency_subscore",
1145
+ "sequencing_subscore",
1146
+ "count_leverage_subscore",
1147
+ "leash_risk_subscore",
1148
+ "times_through_order_penalty",
1149
+ "variance_band_low",
1150
+ "variance_band_high",
1151
+ "predicted_attack_regions",
1152
+ "predicted_damage_regions",
1153
+ "predicted_whiff_regions",
1154
+ "component_source_map",
1155
+ ]
1156
+ return {
1157
+ "market_type": market_type,
1158
+ "rows": display[[c for c in cols if c in display.columns]].head(30).to_dict("records"),
1159
+ }
1160
+
1161
+
1162
  def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | None = None) -> None:
1163
  st.markdown(
1164
  """
 
1257
  </div>
1258
  <div>
1259
  <div class="props-metric-label">Confidence</div>
1260
+ {_build_confidence_metric_html(row)}
1261
  </div>
1262
  </div>
1263
  <div class="props-voice">
 
1337
  </div>
1338
  <div>
1339
  <div class="props-metric-label">Confidence</div>
1340
+ {_build_confidence_metric_html(row)}
1341
  </div>
1342
  </div>
1343
  <div class="props-voice">
 
1412
  "model_voice_for",
1413
  "model_voice_against",
1414
  "confidence_score",
1415
+ "confidence_score_raw",
1416
+ "confidence_score_display",
1417
+ "confidence_source",
1418
  "confidence_bucket",
1419
  "confidence_reasons",
1420
+ "confidence_component_bonuses",
1421
+ "confidence_component_penalties",
1422
+ "confidence_primary_driver",
1423
+ "confidence_summary_label",
1424
  "opportunity_hr_adjustment",
1425
  "expected_pa",
1426
  "lineup_slot_used",
 
1508
  st.caption("Why this rating")
1509
  for line in why_lines:
1510
  st.write(f"- {line}")
1511
+ _render_confidence_breakdown(details.get("best_primary_row") or {})
1512
  render_player_hr_details(details)
1513
  st.divider()
1514