diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index e33e20d..778cfe8 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -104,6 +104,15 @@ namespace HyperTwistCoachDashboardWidgetInternal ); } + FString BuildTemplateLaunchSelectionLaneAnalyticsSummary( + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& SelectionStats, + const TCHAR* LaneLabel + ); + + FString BuildTemplateLaunchSelectionSplitAnalyticsFragment( + const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats + ); + FString BuildTemplateLaunchAnalyticsDetailFragment( const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats ) @@ -114,7 +123,7 @@ namespace HyperTwistCoachDashboardWidgetInternal } return FString::Printf( - TEXT("avg recommended %d | avg focus %d | avg completed attempts %d | total success/failure/timeout/dnf %d/%d/%d/%d | avg mistakes %.2f | review-backed %d | coach-review %d | short set %d | last launch %s | last completed %s"), + TEXT("avg recommended %d | avg focus %d | avg completed attempts %d | total success/failure/timeout/dnf %d/%d/%d/%d | avg mistakes %.2f | review-backed %d | coach-review %d | short set %d | selector split %s | last launch %s | last completed %s"), TemplateStats->AverageRecommendedCaseCount, TemplateStats->AverageFocusCaseCount, TemplateStats->AverageCompletedAttemptCount, @@ -126,6 +135,7 @@ namespace HyperTwistCoachDashboardWidgetInternal TemplateStats->ReviewBackedLaunchCount, TemplateStats->CoachReviewLaunchCount, TemplateStats->ShortReviewSetLaunchCount, + *BuildTemplateLaunchSelectionSplitAnalyticsFragment(TemplateStats), TemplateStats->LastLaunchAtUtc.IsEmpty() ? TEXT("n/a") : *TemplateStats->LastLaunchAtUtc, @@ -199,6 +209,21 @@ namespace HyperTwistCoachDashboardWidgetInternal return Provenance; } + FHyperTwistTrainingTemplateLaunchSelectionProvenance BuildTemplateLaunchPrimarySelectionProvenance( + const FHyperTwistTrainingSessionTemplate& SelectedTemplate + ) + { + FHyperTwistTrainingTemplateLaunchSelectionProvenance Provenance; + Provenance.SelectorSourceLabel = TEXT("CoachDashboard"); + Provenance.ReasonClass = + EHyperTwistTrainingTemplateLaunchSelectionReasonClass::PrimaryDashboardPriority; + Provenance.DecisionSummaryLine = FString::Printf( + TEXT("Selector: selected %s on dashboard priority lane without an analytics tie-break."), + *ResolveDashboardTemplateLaunchDisplayTitle(SelectedTemplate) + ); + return Provenance; + } + FString ResolveTemplateLaunchSelectionReasonClassLabel( const EHyperTwistTrainingTemplateLaunchSelectionReasonClass ReasonClass ) @@ -215,6 +240,67 @@ namespace HyperTwistCoachDashboardWidgetInternal } } + FString BuildTemplateLaunchSelectionLaneAnalyticsSummary( + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& SelectionStats, + const TCHAR* LaneLabel + ) + { + if (!SelectionStats.IsStructurallyValid()) + { + return FString::Printf(TEXT("%s none yet"), LaneLabel); + } + + return FString::Printf( + TEXT("%s launches %d | outcomes %d | success %.2f | avg mistakes %.2f"), + LaneLabel, + SelectionStats.LaunchCount, + SelectionStats.GetOutcomeRunCount(), + SelectionStats.OverallSuccessRate, + SelectionStats.AverageMistakesPerOutcomeRun + ); + } + + FString BuildTemplateLaunchSelectionSplitAnalyticsFragment( + const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats + ) + { + if (TemplateStats == nullptr || !TemplateStats->IsStructurallyValid()) + { + return TEXT("no dashboard selector split analytics are available for this template id yet"); + } + + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& PrimaryStats = + TemplateStats->DashboardPrimarySelectionStats; + const FHyperTwistTrainingTemplateLaunchSelectionScopedStats& AnalyticsTieBreakStats = + TemplateStats->DashboardAnalyticsTieBreakStats; + if (!PrimaryStats.IsStructurallyValid() && !AnalyticsTieBreakStats.IsStructurallyValid()) + { + return TEXT("no dashboard selector split analytics are available for this template id yet"); + } + + FString Fragment = FString::Printf( + TEXT("%s | %s"), + *BuildTemplateLaunchSelectionLaneAnalyticsSummary(PrimaryStats, TEXT("priority")), + *BuildTemplateLaunchSelectionLaneAnalyticsSummary( + AnalyticsTieBreakStats, + TEXT("tie-break")) + ); + if (PrimaryStats.IsStructurallyValid() + && AnalyticsTieBreakStats.IsStructurallyValid() + && PrimaryStats.GetOutcomeRunCount() > 0 + && AnalyticsTieBreakStats.GetOutcomeRunCount() > 0) + { + Fragment += FString::Printf( + TEXT(" | delta success %+.2f | delta mistakes %+.2f"), + AnalyticsTieBreakStats.OverallSuccessRate - PrimaryStats.OverallSuccessRate, + AnalyticsTieBreakStats.AverageMistakesPerOutcomeRun + - PrimaryStats.AverageMistakesPerOutcomeRun + ); + } + + return Fragment; + } + FString BuildTemplateLaunchSelectionDetailFragment( const FHyperTwistTrainingTemplateLaunchSelectionProvenance* SelectionProvenance ) @@ -228,14 +314,29 @@ namespace HyperTwistCoachDashboardWidgetInternal ? SelectionProvenance->PeerTemplateTitle : (!SelectionProvenance->PeerTemplateId.IsEmpty() ? SelectionProvenance->PeerTemplateId - : TEXT("n/a")); + : TEXT("n/a")); + if (!SelectionProvenance->PeerTemplateTitle.IsEmpty() + || !SelectionProvenance->PeerTemplateId.IsEmpty()) + { + return FString::Printf( + TEXT("Selector %s | reason %s | peer %s | %s"), + SelectionProvenance->SelectorSourceLabel.IsEmpty() + ? TEXT("n/a") + : *SelectionProvenance->SelectorSourceLabel, + *ResolveTemplateLaunchSelectionReasonClassLabel(SelectionProvenance->ReasonClass), + *PeerTemplateLabel, + SelectionProvenance->DecisionSummaryLine.IsEmpty() + ? TEXT("no retained selector decision summary") + : *SelectionProvenance->DecisionSummaryLine + ); + } + return FString::Printf( - TEXT("Selector %s | reason %s | peer %s | %s"), + TEXT("Selector %s | reason %s | %s"), SelectionProvenance->SelectorSourceLabel.IsEmpty() ? TEXT("n/a") : *SelectionProvenance->SelectorSourceLabel, *ResolveTemplateLaunchSelectionReasonClassLabel(SelectionProvenance->ReasonClass), - *PeerTemplateLabel, SelectionProvenance->DecisionSummaryLine.IsEmpty() ? TEXT("no retained selector decision summary") : *SelectionProvenance->DecisionSummaryLine @@ -3834,6 +3935,13 @@ FHyperTwistTrainingRunState UHyperTwistCoachDashboardWidget::StartPreferredActiv } const FHyperTwistTrainingSessionTemplate LaunchedTemplate = *PreferredTemplate; + if (!LaunchSelectionProvenance.IsStructurallyValid()) + { + LaunchSelectionProvenance = + HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchPrimarySelectionProvenance( + LaunchedTemplate + ); + } const FString ResolvedUserId = LaunchedTemplate.UserId.IsEmpty() ? DefaultUserId : LaunchedTemplate.UserId; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index 68738eb..f772b81 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -9146,12 +9146,21 @@ UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser( struct FTemplateLaunchScopedStatsAccumulator { + struct FSelectionScopedAccumulator + { + FHyperTwistTrainingTemplateLaunchSelectionScopedStats Stats; + int64 MistakeAccumulator = 0; + int32 OutcomeRunSampleCount = 0; + }; + FHyperTwistTrainingTemplateLaunchScopedStats Stats; int64 RecommendedCaseAccumulator = 0; int64 FocusCaseAccumulator = 0; int64 CompletedAttemptAccumulator = 0; int64 MistakeAccumulator = 0; int32 OutcomeRunSampleCount = 0; + FSelectionScopedAccumulator DashboardPrimarySelectionAccumulator; + FSelectionScopedAccumulator DashboardAnalyticsTieBreakSelectionAccumulator; }; const FString ResolvedReferenceUtc = @@ -9245,6 +9254,59 @@ UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser( Accumulator.Stats.ShortReviewSetLaunchCount += 1; } + auto AccumulateSelectionScopedStats = + [&RunRecord]( + FTemplateLaunchScopedStatsAccumulator::FSelectionScopedAccumulator& SelectionAccumulator, + const FHyperTwistTrainingTemplateLaunchSelectionProvenance& SelectionProvenance) + { + SelectionAccumulator.Stats.SelectorSourceLabel = SelectionProvenance.SelectorSourceLabel; + SelectionAccumulator.Stats.ReasonClass = SelectionProvenance.ReasonClass; + SelectionAccumulator.Stats.LaunchCount += 1; + SelectionAccumulator.Stats.TotalCompletedAttemptCount += + RunRecord.Summary.CompletedAttemptCount; + SelectionAccumulator.Stats.TotalSuccessCount += RunRecord.Summary.SuccessCount; + + if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Completed) + { + SelectionAccumulator.Stats.CompletedRunCount += 1; + } + else if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Aborted) + { + SelectionAccumulator.Stats.AbortedRunCount += 1; + } + else + { + SelectionAccumulator.Stats.OpenRunCount += 1; + } + + if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Completed + || RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Aborted) + { + SelectionAccumulator.MistakeAccumulator += RunRecord.Summary.TotalMistakes; + SelectionAccumulator.OutcomeRunSampleCount += 1; + } + }; + if (Provenance.SelectionProvenance.IsStructurallyValid() + && Provenance.SelectionProvenance.SelectorSourceLabel == TEXT("CoachDashboard")) + { + if (Provenance.SelectionProvenance.ReasonClass == + EHyperTwistTrainingTemplateLaunchSelectionReasonClass::PrimaryDashboardPriority) + { + AccumulateSelectionScopedStats( + Accumulator.DashboardPrimarySelectionAccumulator, + Provenance.SelectionProvenance + ); + } + else if (Provenance.SelectionProvenance.ReasonClass == + EHyperTwistTrainingTemplateLaunchSelectionReasonClass::AnalyticsTieBreak) + { + AccumulateSelectionScopedStats( + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator, + Provenance.SelectionProvenance + ); + } + } + if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Completed) { Accumulator.Stats.CompletedRunCount += 1; @@ -9301,6 +9363,37 @@ UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser( ? static_cast(Accumulator.MistakeAccumulator) / static_cast(Accumulator.OutcomeRunSampleCount) : 0.0f; + Accumulator.DashboardPrimarySelectionAccumulator.Stats.OverallSuccessRate = + Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalCompletedAttemptCount > 0 + ? static_cast( + Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalSuccessCount) + / static_cast( + Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalCompletedAttemptCount) + : 0.0f; + Accumulator.DashboardPrimarySelectionAccumulator.Stats.AverageMistakesPerOutcomeRun = + Accumulator.DashboardPrimarySelectionAccumulator.OutcomeRunSampleCount > 0 + ? static_cast(Accumulator.DashboardPrimarySelectionAccumulator.MistakeAccumulator) + / static_cast( + Accumulator.DashboardPrimarySelectionAccumulator.OutcomeRunSampleCount) + : 0.0f; + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.OverallSuccessRate = + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalCompletedAttemptCount > 0 + ? static_cast( + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalSuccessCount) + / static_cast( + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalCompletedAttemptCount) + : 0.0f; + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.AverageMistakesPerOutcomeRun = + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.OutcomeRunSampleCount > 0 + ? static_cast( + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.MistakeAccumulator) + / static_cast( + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.OutcomeRunSampleCount) + : 0.0f; + Accumulator.Stats.DashboardPrimarySelectionStats = + Accumulator.DashboardPrimarySelectionAccumulator.Stats; + Accumulator.Stats.DashboardAnalyticsTieBreakStats = + Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats; if (Accumulator.Stats.IsStructurallyValid()) { ScopedStats.Add(Accumulator.Stats); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index 330a3b4..bb9bb9e 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -2013,6 +2013,54 @@ struct FHyperTwistTrainingDeckScopedStats } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingTemplateLaunchSelectionScopedStats +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SelectorSourceLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingTemplateLaunchSelectionReasonClass ReasonClass = + EHyperTwistTrainingTemplateLaunchSelectionReasonClass::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LaunchCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CompletedRunCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AbortedRunCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 OpenRunCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalCompletedAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float OverallSuccessRate = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float AverageMistakesPerOutcomeRun = 0.0f; + + bool IsStructurallyValid() const + { + return ReasonClass != EHyperTwistTrainingTemplateLaunchSelectionReasonClass::None + && LaunchCount > 0; + } + + int32 GetOutcomeRunCount() const + { + return CompletedRunCount + AbortedRunCount; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingTemplateLaunchScopedStats { @@ -2107,6 +2155,12 @@ struct FHyperTwistTrainingTemplateLaunchScopedStats UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") float AverageMistakesPerOutcomeRun = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTemplateLaunchSelectionScopedStats DashboardPrimarySelectionStats; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTemplateLaunchSelectionScopedStats DashboardAnalyticsTieBreakStats; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") FString LastLaunchAtUtc; diff --git a/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SELECTION_SPLIT_ANALYTICS_PACKET_2026-05-07.md b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SELECTION_SPLIT_ANALYTICS_PACKET_2026-05-07.md new file mode 100644 index 0000000..e4e30f9 --- /dev/null +++ b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SELECTION_SPLIT_ANALYTICS_PACKET_2026-05-07.md @@ -0,0 +1,96 @@ +# HyperTwist Phase 4 template launch selection split analytics packet + +Created on `2026-05-07` + +Status: + +- first-party HyperTwist packet +- bounded Phase `4` dashboard template analytics follow-through slice + +## Purpose + +This packet splits repo-backed template launch outcomes by dashboard selector reason class so operators can compare plain priority picks against analytics tie-break picks. + +The open tasks were: + +- stop treating persisted selector reason class as audit-only metadata +- derive per-template outcome aggregates separately for dashboard primary-priority launches and analytics tie-break launches +- surface that stored split back into the dashboard template launch detail, history, and recap lanes + +It is not: + +- a new dashboard selector rewrite packet +- a non-dashboard analytics packet +- a new repository history schema packet beyond template-scoped aggregates +- a broader coach weighting redesign packet + +## Scope + +Bounded lane: + +- add a small selection-scoped aggregate struct alongside template launch scoped stats +- derive dashboard `PrimaryDashboardPriority` and `AnalyticsTieBreak` aggregates from persisted run provenance +- stamp primary dashboard launches with explicit selector provenance so both selector lanes are counted +- append selector split analytics to the existing per-template analytics detail fragment already used by dashboard launch, history, and recap surfaces + +Out of scope: + +- changing the primary launch priority rules +- changing analytics tie-break formulas +- adding a new dedicated selector analytics screen +- aggregating selector lanes for non-dashboard launch sources + +## Why this was the right next packet + +The previous packet persisted why the dashboard picked a template. + +That closed the audit gap for individual runs, but it still left a product-level question unanswered: + +- did analytics tie-break picks actually outperform plain dashboard priority picks for the same template lane +- or were they only explainable, not measurable + +So the next honest move was: + +- treat selector reason class as an analytics dimension, not only a history detail +- derive that split directly from repo-backed run records +- and surface the comparison where operators already inspect template launch outcomes + +## What landed + +Primary code changes: + +- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h` + - added `FHyperTwistTrainingTemplateLaunchSelectionScopedStats` + - added per-template `DashboardPrimarySelectionStats` and `DashboardAnalyticsTieBreakStats` +- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp` + - derives selector-scoped launch, completion, abort, success, and average-mistake aggregates from persisted template launch provenance + - splits those aggregates specifically for dashboard primary-priority vs analytics tie-break launches +- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp` + - stamps primary dashboard template launches with explicit selector provenance + - adds selector split formatting helpers + - appends stored selector split analytics to the existing template analytics detail fragment used by the live launch row, template launch history, and run recap + +## Product effect + +Template launch analytics are now split by how the dashboard chose the template: + +- plain dashboard priority launches and analytics tie-break launches accumulate separately per template id +- the dashboard can compare those two lanes using repo-backed success and mistake outcomes +- the live template launch section, template launch history, and template-backed run recap all inherit that selector split automatically through the shared analytics detail fragment +- templates with no stored selector split history remain explicit about that gap instead of implying a comparison exists + +## Acceptance criteria + +- repo-backed template launch analytics split dashboard primary-priority vs analytics tie-break launches +- primary dashboard launches record selector provenance so the primary lane is measurable +- dashboard template analytics detail surfaces show the selector split comparison when data exists +- full product build succeeds + +## Validation checklist + +1. build `UnrealHyperTwist.sln` / `UnrealHyperTwistEditor` +2. confirm template-scoped stats derive separate aggregates for dashboard primary and analytics tie-break selection reasons +3. confirm live template launch detail, template launch history, and template-backed recap all show the selector split analytics fragment +4. confirm templates without selector split history show the explicit empty-state message + +That is the packet.