diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 778cfe8..edbe694 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -651,6 +651,13 @@ namespace HyperTwistCoachDashboardWidgetInternal return !Template.UserId.IsEmpty() ? Template.UserId : DefaultUserId; } + constexpr int32 TemplateLaunchAnalyticsTieBreakMinimumOutcomeRuns = 2; + constexpr int32 TemplateLaunchSelectionTieBreakConfidenceMinimumTieBreakOutcomeRuns = 3; + constexpr int32 TemplateLaunchSelectionTieBreakConfidenceMinimumPrimaryOutcomeRuns = 2; + constexpr int32 TemplateLaunchSelectionTieBreakConfidenceMinimumScoreDelta = 2; + constexpr float TemplateLaunchSelectionTieBreakConfidenceMinimumSuccessDelta = 0.05f; + constexpr float TemplateLaunchSelectionTieBreakConfidenceMinimumMistakeImprovement = 0.5f; + int32 CountTemplateLaunchOutcomeRuns(const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats) { return TemplateStats != nullptr && TemplateStats->IsStructurallyValid() @@ -658,6 +665,52 @@ namespace HyperTwistCoachDashboardWidgetInternal : 0; } + int32 CountTemplateLaunchSelectionOutcomeRuns( + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats* SelectionStats + ) + { + return SelectionStats != nullptr && SelectionStats->IsStructurallyValid() + ? SelectionStats->GetOutcomeRunCount() + : 0; + } + + int32 BuildTemplateLaunchAnalyticsOutcomeSignalScore( + const int32 OutcomeRuns, + const float SuccessRate, + const float AverageMistakesPerOutcomeRun, + const int32 AbortedRunCount + ) + { + if (OutcomeRuns <= 0) + { + return 0; + } + + int32 Score = 0; + Score += FMath::Clamp(OutcomeRuns, 0, 4); + Score += FMath::Clamp(FMath::RoundToInt(SuccessRate * 10.0f), 0, 10); + Score -= FMath::Clamp(FMath::RoundToInt(AverageMistakesPerOutcomeRun * 2.0f), 0, 8); + Score -= FMath::Clamp(AbortedRunCount, 0, 3); + return Score; + } + + int32 BuildTemplateLaunchSelectionLaneAnalyticsScore( + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats* SelectionStats + ) + { + if (SelectionStats == nullptr || !SelectionStats->IsStructurallyValid()) + { + return 0; + } + + return BuildTemplateLaunchAnalyticsOutcomeSignalScore( + CountTemplateLaunchSelectionOutcomeRuns(SelectionStats), + SelectionStats->OverallSuccessRate, + SelectionStats->AverageMistakesPerOutcomeRun, + SelectionStats->AbortedRunCount + ); + } + int32 BuildTemplateLaunchAnalyticsTieBreakScore( const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats ) @@ -673,12 +726,54 @@ namespace HyperTwistCoachDashboardWidgetInternal return 0; } - int32 Score = 0; - Score += FMath::Clamp(OutcomeRuns, 0, 4); - Score += FMath::Clamp(FMath::RoundToInt(TemplateStats->OverallSuccessRate * 10.0f), 0, 10); - Score -= FMath::Clamp(FMath::RoundToInt(TemplateStats->AverageMistakesPerOutcomeRun * 2.0f), 0, 8); - Score -= FMath::Clamp(TemplateStats->AbortedRunCount, 0, 3); - return Score; + return BuildTemplateLaunchAnalyticsOutcomeSignalScore( + OutcomeRuns, + TemplateStats->OverallSuccessRate, + TemplateStats->AverageMistakesPerOutcomeRun, + TemplateStats->AbortedRunCount + ); + } + + int32 BuildTemplateLaunchAnalyticsTieBreakConfidenceBonus( + const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats + ) + { + if (TemplateStats == nullptr || !TemplateStats->IsStructurallyValid()) + { + return 0; + } + + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& TieBreakStats = + TemplateStats->DashboardAnalyticsTieBreakStats; + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& PrimaryStats = + TemplateStats->DashboardPrimarySelectionStats; + const int32 TieBreakOutcomeRuns = CountTemplateLaunchSelectionOutcomeRuns(&TieBreakStats); + const int32 PrimaryOutcomeRuns = CountTemplateLaunchSelectionOutcomeRuns(&PrimaryStats); + if (TieBreakOutcomeRuns < TemplateLaunchSelectionTieBreakConfidenceMinimumTieBreakOutcomeRuns + || PrimaryOutcomeRuns < TemplateLaunchSelectionTieBreakConfidenceMinimumPrimaryOutcomeRuns) + { + return 0; + } + + const int32 TieBreakScore = BuildTemplateLaunchSelectionLaneAnalyticsScore(&TieBreakStats); + const int32 PrimaryScore = BuildTemplateLaunchSelectionLaneAnalyticsScore(&PrimaryStats); + const int32 ScoreDelta = TieBreakScore - PrimaryScore; + if (ScoreDelta <= 0) + { + return 0; + } + + const float SuccessDelta = TieBreakStats.OverallSuccessRate - PrimaryStats.OverallSuccessRate; + const float MistakeImprovement = + PrimaryStats.AverageMistakesPerOutcomeRun - TieBreakStats.AverageMistakesPerOutcomeRun; + if (ScoreDelta < TemplateLaunchSelectionTieBreakConfidenceMinimumScoreDelta + && SuccessDelta < TemplateLaunchSelectionTieBreakConfidenceMinimumSuccessDelta + && MistakeImprovement < TemplateLaunchSelectionTieBreakConfidenceMinimumMistakeImprovement) + { + return 0; + } + + return 1; } bool ShouldPreferTemplateByAnalytics( @@ -688,13 +783,18 @@ namespace HyperTwistCoachDashboardWidgetInternal { const int32 CandidateOutcomeRuns = CountTemplateLaunchOutcomeRuns(CandidateStats); const int32 IncumbentOutcomeRuns = CountTemplateLaunchOutcomeRuns(IncumbentStats); - if (CandidateOutcomeRuns < 2 && IncumbentOutcomeRuns < 2) + if (CandidateOutcomeRuns < TemplateLaunchAnalyticsTieBreakMinimumOutcomeRuns + && IncumbentOutcomeRuns < TemplateLaunchAnalyticsTieBreakMinimumOutcomeRuns) { return false; } - const int32 CandidateScore = BuildTemplateLaunchAnalyticsTieBreakScore(CandidateStats); - const int32 IncumbentScore = BuildTemplateLaunchAnalyticsTieBreakScore(IncumbentStats); + const int32 CandidateScore = + BuildTemplateLaunchAnalyticsTieBreakScore(CandidateStats) + + BuildTemplateLaunchAnalyticsTieBreakConfidenceBonus(CandidateStats); + const int32 IncumbentScore = + BuildTemplateLaunchAnalyticsTieBreakScore(IncumbentStats) + + BuildTemplateLaunchAnalyticsTieBreakConfidenceBonus(IncumbentStats); if (CandidateScore != IncumbentScore) { return CandidateScore > IncumbentScore; diff --git a/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_TIEBREAK_CONFIDENCE_GUARD_PACKET_2026-05-07.md b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_TIEBREAK_CONFIDENCE_GUARD_PACKET_2026-05-07.md new file mode 100644 index 0000000..0717782 --- /dev/null +++ b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_TIEBREAK_CONFIDENCE_GUARD_PACKET_2026-05-07.md @@ -0,0 +1,92 @@ +# HyperTwist Phase 4 template launch tie-break confidence guard packet + +Created on `2026-05-07` + +Status: + +- first-party HyperTwist packet +- bounded Phase `4` dashboard template selector refinement slice + +## Purpose + +This packet stops sparse analytics tie-break history from reinforcing future dashboard template choices unless that tie-break lane has enough stored outcomes to outperform the same template's plain dashboard-priority lane. + +The open tasks were: + +- consume the new selector-split analytics back into launch selection rather than leaving them as display-only stats +- keep analytics tie-break selection conservative under sparse history +- require stored tie-break lane evidence to clear a real sample-and-outperformance guard before it can strengthen a future tie decision + +It is not: + +- a change to the primary review or recovery ranking rules +- a new repository aggregation packet +- a new selection provenance schema packet +- a dashboard history or recap redesign packet + +## Scope + +Bounded lane: + +- reuse the existing stored outcome tie-break scorer for both whole-template and selector-lane analytics +- derive a small confidence bonus from selector-split stats only when the analytics tie-break lane has enough outcome runs +- require that tie-break lane to beat the same template's dashboard primary lane on stronger-than-noise outcome signal before awarding that bonus +- keep the bonus small so analytics confidence nudges tied selections instead of rewriting the launch model + +Out of scope: + +- changing which templates are eligible for dashboard launch +- changing how repo-backed selector split stats are stored +- persisting a new selector reason class +- changing non-dashboard template launch paths + +## Why this was the right next packet + +The previous packet made it possible to see whether analytics tie-break launches were outperforming plain priority launches. + +That immediately exposed the next gap: + +- the selector could now display that split +- but the selector itself still treated all stored outcome signal the same +- and a handful of analytics-tie-break wins could start reinforcing future tied selections without proving that the tie-break lane really outperformed the template's ordinary dashboard-priority lane + +So the next honest move was: + +- keep the current overall analytics tie-break intact +- let selector-split history contribute only as a small confidence nudge +- and gate that nudge behind both minimum sample size and meaningful lane outperformance + +## What landed + +Primary code changes: + +- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp` + - factored the outcome-signal scorer so whole-template and selector-lane analytics use the same bounded score math + - added selector-lane outcome counters and a small confidence bonus path + - requires the `AnalyticsTieBreak` lane to have enough stored outcome runs and to beat the same template's `PrimaryDashboardPriority` lane on meaningful success/mistake/score signal before that bonus is applied + - folds that bonus into the existing equal-priority dashboard analytics tie-break without changing the primary launch ranking rules + +## Product effect + +Dashboard template tie-break selection is now more resistant to self-reinforcing noise: + +- tied templates still use the same overall analytics tie-break path as before +- selector-split history only strengthens a template when the tie-break lane has enough actual outcome runs +- that tie-break lane must also outperform the template's own primary dashboard lane before it earns extra confidence +- sparse or inconclusive tie-break history no longer adds weight just because it exists + +## Acceptance criteria + +- existing primary dashboard template ranking rules stay unchanged +- selector-split analytics influence future tied selections only after clearing a minimum-sample guard +- tie-break lane confidence is only awarded when it meaningfully beats the same template's primary dashboard lane +- full product build succeeds + +## Validation checklist + +1. build `UnrealHyperTwist.sln` / `UnrealHyperTwistEditor` +2. confirm equal-priority template selection still uses the existing analytics tie-break path +3. confirm selector-split history only adds confidence when the analytics tie-break lane has enough outcome runs and outperforms the primary lane +4. confirm sparse selector-split history does not create extra preference on its own + +That is the packet.