diff --git a/src/ui/main_ui_elite_recommendation.py b/src/ui/main_ui_elite_recommendation.py new file mode 100644 index 0000000..eabaf32 --- /dev/null +++ b/src/ui/main_ui_elite_recommendation.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from src.ui.full_pipeline_elite_status import ( + DEFAULT_ELITE_EVIDENCE_ROOT, + UIElitePipelineStatus, + full_pipeline_elite_policy_for_ui, + read_full_pipeline_elite_status, +) +from src.ui.move_policy import ( + BOT_MOVE_POLICY_STATISTICAL, + bot_move_policy_label, + normalize_bot_move_policy, +) + + +@dataclass(frozen=True) +class MainUIEliteRecommendation: + username: str + time_class: str + status: str + decision: str + reason: str + recommended_policy: str + move_policy: str + move_policy_label: str + evidence_path: Path + ready: bool + technical_error: bool + should_select_policy: bool + + +def _title_for_decision(decision: str) -> str: + if decision == "elite_guarded_ready": + return "Elite Guarded ready" + if decision == "elite_guarded_strict_ready": + return "Elite Guarded Strict ready" + if decision == "needs_more_evidence": + return "Elite evidence needs more data" + if decision == "elite_guarded_blocked": + return "Elite guarded runtime blocked" + return "Elite evidence unavailable" + + +def build_main_ui_elite_recommendation( + *, + username: object, + time_class: object, + evidence_root: Path | str = DEFAULT_ELITE_EVIDENCE_ROOT, + evidence_path: Path | None = None, +) -> MainUIEliteRecommendation: + status = read_full_pipeline_elite_status( + username=username, + time_class=time_class, + evidence_root=evidence_root, + evidence_path=evidence_path, + ) + policy = full_pipeline_elite_policy_for_ui(status) + normalized_policy = normalize_bot_move_policy(policy) + + return MainUIEliteRecommendation( + username=status.username, + time_class=status.time_class, + status=status.status, + decision=status.decision, + reason=status.reason, + recommended_policy=status.recommended_policy, + move_policy=normalized_policy, + move_policy_label=bot_move_policy_label(normalized_policy), + evidence_path=status.evidence_path, + ready=status.ready and not status.technical_error, + technical_error=status.technical_error, + should_select_policy=( + status.ready + and not status.technical_error + and normalized_policy != BOT_MOVE_POLICY_STATISTICAL + ), + ) + + +def recommendation_from_elite_status( + status: UIElitePipelineStatus, +) -> MainUIEliteRecommendation: + policy = full_pipeline_elite_policy_for_ui(status) + normalized_policy = normalize_bot_move_policy(policy) + + return MainUIEliteRecommendation( + username=status.username, + time_class=status.time_class, + status=status.status, + decision=status.decision, + reason=status.reason, + recommended_policy=status.recommended_policy, + move_policy=normalized_policy, + move_policy_label=bot_move_policy_label(normalized_policy), + evidence_path=status.evidence_path, + ready=status.ready and not status.technical_error, + technical_error=status.technical_error, + should_select_policy=( + status.ready + and not status.technical_error + and normalized_policy != BOT_MOVE_POLICY_STATISTICAL + ), + ) + + +def render_main_ui_elite_recommendation( + recommendation: MainUIEliteRecommendation, +) -> str: + lines = [ + f"Full Pipeline Elite Status: {_title_for_decision(recommendation.decision)}", + f"Recommended UI policy: {recommendation.move_policy_label}", + f"Move policy key: {recommendation.move_policy}", + f"Reason: {recommendation.reason}", + f"Evidence report: {recommendation.evidence_path}", + ] + + if recommendation.should_select_policy: + lines.append("UI recommendation: ready; this policy can be selected.") + else: + lines.append("UI recommendation: not ready; keep current/statistical policy.") + + return "\n".join(lines) + + +def main_ui_elite_recommendation_lines( + recommendation: MainUIEliteRecommendation | None, +) -> list[str]: + if recommendation is None: + return [] + + lines = [ + f"Full Pipeline Elite Status: {_title_for_decision(recommendation.decision)}", + f"Recommended UI policy: {recommendation.move_policy_label}", + f"Move policy key: {recommendation.move_policy}", + ] + + if not recommendation.should_select_policy: + lines.append("Elite policy not auto-selected until evidence is ready.") + + return lines + + +def _set_combo_policy(combo: Any, move_policy: str) -> bool: + if combo is None: + return False + + try: + index = combo.findData(move_policy) + except Exception: + index = -1 + + if index is None or int(index) < 0: + return False + + try: + combo.blockSignals(True) + except Exception: + pass + + try: + combo.setCurrentIndex(int(index)) + finally: + try: + combo.blockSignals(False) + except Exception: + pass + + return True + + +def apply_main_ui_elite_recommendation_to_window( + window: Any, + *, + username: object | None = None, + time_class: object | None = None, + evidence_root: Path | str = DEFAULT_ELITE_EVIDENCE_ROOT, + evidence_path: Path | None = None, + auto_select_ready: bool = True, +) -> MainUIEliteRecommendation: + recommendation = build_main_ui_elite_recommendation( + username=username if username is not None else getattr(window, "username", ""), + time_class=time_class if time_class is not None else getattr(window, "time_class", ""), + evidence_root=evidence_root, + evidence_path=evidence_path, + ) + + setattr(window, "main_ui_elite_recommendation", recommendation) + setattr(window, "full_pipeline_elite_recommendation", recommendation) + + if auto_select_ready and recommendation.should_select_policy: + setattr(window, "move_policy", recommendation.move_policy) + _set_combo_policy( + getattr(window, "move_policy_combo", None), + recommendation.move_policy, + ) + + sync = getattr(window, "_sync_beta_dashboard_policy", None) + if callable(sync): + sync() + + return recommendation diff --git a/src/ui/main_window_parts/game.py b/src/ui/main_window_parts/game.py index fd45726..8f55850 100644 --- a/src/ui/main_window_parts/game.py +++ b/src/ui/main_window_parts/game.py @@ -30,6 +30,9 @@ render_beta_dashboard, set_beta_dashboard_policy, ) +from src.ui.main_ui_elite_recommendation import ( + main_ui_elite_recommendation_lines, +) class MainWindowGameMixin: @@ -1030,6 +1033,17 @@ def _show_persona_summary( "", ] + elite_lines = main_ui_elite_recommendation_lines( + getattr( + self, + "main_ui_elite_recommendation", + None, + ) + ) + if elite_lines: + lines.extend(elite_lines) + lines.append("") + for explanation in explanations: lines.append( f"• {explanation}" @@ -1091,7 +1105,8 @@ def _show_bot_decision( ) else: lines.append( - "Move policy: Statistical selector" + "Move policy: " + + bot_move_policy_label(policy) ) if isinstance(guarded_hybrid_result, dict): diff --git a/src/ui/main_window_parts/persona.py b/src/ui/main_window_parts/persona.py index b7e1dc8..4c1de1f 100644 --- a/src/ui/main_window_parts/persona.py +++ b/src/ui/main_window_parts/persona.py @@ -14,6 +14,9 @@ from src.persona.persona_manager import PersonaManagerError from src.ui.persona_creation_worker import PersonaCreationThread from src.ui.persona_progress_dialog import PersonaProgressDialog +from src.ui.main_ui_elite_recommendation import ( + apply_main_ui_elite_recommendation_to_window, +) class MainWindowPersonaMixin: @@ -260,6 +263,12 @@ def _load_persona( False ) + apply_main_ui_elite_recommendation_to_window( + self, + username=normalized_username, + time_class=selected_mode, + ) + self._refresh_saved_personas( select_username=normalized_username ) @@ -276,6 +285,24 @@ def _load_persona( if self.direct_ranker_shadow.enabled else "direct ranker shadow off" ) + elite_recommendation = getattr( + self, + "main_ui_elite_recommendation", + None, + ) + elite_status = "" + if ( + elite_recommendation is not None + and getattr( + elite_recommendation, + "should_select_policy", + False, + ) + ): + elite_status = ( + " — recommended: " + + str(elite_recommendation.move_policy_label) + ) self.status_label.setText( f"Persona loaded: " @@ -283,6 +310,7 @@ def _load_persona( f"{selected_mode.upper()} — " f"{shadow_status} — " f"{direct_status}" + f"{elite_status}" ) if start_new_game: diff --git a/tests/test_main_ui_elite_recommendation.py b/tests/test_main_ui_elite_recommendation.py new file mode 100644 index 0000000..e3a1f98 --- /dev/null +++ b/tests/test_main_ui_elite_recommendation.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from src.ui.main_ui_elite_recommendation import ( + apply_main_ui_elite_recommendation_to_window, + build_main_ui_elite_recommendation, + main_ui_elite_recommendation_lines, + render_main_ui_elite_recommendation, +) +from src.ui.move_policy import ( + BOT_MOVE_POLICY_DIRECT_RANKER_ELITE_GUARDED_STRICT, + BOT_MOVE_POLICY_STATISTICAL, +) + + +class FakeCombo: + def __init__(self) -> None: + self.data = [ + BOT_MOVE_POLICY_STATISTICAL, + BOT_MOVE_POLICY_DIRECT_RANKER_ELITE_GUARDED_STRICT, + ] + self.current_index = 0 + self.blocked: list[bool] = [] + + def findData(self, value: str) -> int: + try: + return self.data.index(value) + except ValueError: + return -1 + + def setCurrentIndex(self, index: int) -> None: + self.current_index = index + + def blockSignals(self, blocked: bool) -> None: + self.blocked.append(blocked) + + +def write_evidence( + root: Path, + *, + username: str = "hikaru", + time_class: str = "rapid", + decision: str = "elite_guarded_strict_ready", + recommended_policy: str = "Direct Ranker Elite Guarded Strict", +) -> Path: + path = root / username / f"{time_class}_elite_guarded_evidence.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "decision": decision, + "reason": "elite_guarded_evidence_ready", + "recommended_policy": recommended_policy, + "failed_checks": [], + } + ), + encoding="utf-8", + ) + return path + + +def test_main_ui_elite_recommendation_reads_strict_ready( + tmp_path: Path, +) -> None: + write_evidence(tmp_path) + + recommendation = build_main_ui_elite_recommendation( + username="Hikaru", + time_class="rapid", + evidence_root=tmp_path, + ) + + assert recommendation.ready is True + assert recommendation.should_select_policy is True + assert recommendation.move_policy == ( + BOT_MOVE_POLICY_DIRECT_RANKER_ELITE_GUARDED_STRICT + ) + assert recommendation.move_policy_label == ( + "Direct Ranker elite guarded strict" + ) + + rendered = render_main_ui_elite_recommendation(recommendation) + assert "Full Pipeline Elite Status: Elite Guarded Strict ready" in rendered + assert "Move policy key: direct_ranker_elite_guarded_strict" in rendered + + +def test_main_ui_elite_recommendation_missing_evidence_is_safe( + tmp_path: Path, +) -> None: + recommendation = build_main_ui_elite_recommendation( + username="hikaru", + time_class="rapid", + evidence_root=tmp_path, + ) + + assert recommendation.ready is False + assert recommendation.should_select_policy is False + assert recommendation.move_policy == BOT_MOVE_POLICY_STATISTICAL + assert "Elite evidence unavailable" in "\n".join( + main_ui_elite_recommendation_lines(recommendation) + ) + + +def test_apply_main_ui_elite_recommendation_selects_combo_policy( + tmp_path: Path, +) -> None: + write_evidence(tmp_path) + combo = FakeCombo() + window = SimpleNamespace( + username="hikaru", + time_class="rapid", + move_policy=BOT_MOVE_POLICY_STATISTICAL, + move_policy_combo=combo, + ) + + calls: list[str] = [] + + def sync() -> None: + calls.append("sync") + + window._sync_beta_dashboard_policy = sync + + recommendation = apply_main_ui_elite_recommendation_to_window( + window, + evidence_root=tmp_path, + ) + + assert recommendation.should_select_policy is True + assert window.move_policy == BOT_MOVE_POLICY_DIRECT_RANKER_ELITE_GUARDED_STRICT + assert combo.current_index == 1 + assert calls == ["sync"] + + +def test_apply_main_ui_elite_recommendation_does_not_select_when_not_ready( + tmp_path: Path, +) -> None: + path = write_evidence( + tmp_path, + decision="needs_more_evidence", + recommended_policy="Direct Ranker Elite Guarded after more evidence", + ) + combo = FakeCombo() + window = SimpleNamespace( + username="hikaru", + time_class="rapid", + move_policy=BOT_MOVE_POLICY_STATISTICAL, + move_policy_combo=combo, + ) + + recommendation = apply_main_ui_elite_recommendation_to_window( + window, + evidence_path=path, + ) + + assert recommendation.should_select_policy is False + assert window.move_policy == BOT_MOVE_POLICY_STATISTICAL + assert combo.current_index == 0