From 2d20d0ba60df8777882c85d5f2e1e3efec55a4dc Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Wed, 6 May 2026 23:56:30 +0200 Subject: [PATCH] Add template launch scoped analytics --- .../HyperTwistCoachDashboardWidget.cpp | 156 ++++++++++-- .../HyperTwistTrainingRepositoryLibrary.cpp | 229 ++++++++++++++++++ .../HyperTwistCoachDashboardWidget.h | 6 + .../HyperTwistTrainingRepositoryLibrary.h | 15 ++ .../HyperTwistTrainingTypes.h | 109 +++++++++ ...UNCH_SCOPED_ANALYTICS_PACKET_2026-05-06.md | 102 ++++++++ 6 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SCOPED_ANALYTICS_PACKET_2026-05-06.md diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index c4c0b5c..ee5577d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -83,6 +83,56 @@ namespace HyperTwistCoachDashboardWidgetInternal return Entry; } + FString BuildTemplateLaunchAnalyticsOutcomeFragment( + const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats + ) + { + if (TemplateStats == nullptr || !TemplateStats->IsStructurallyValid()) + { + return TEXT("no stored template-backed outcomes yet"); + } + + return FString::Printf( + TEXT("launches %d | completed %d | aborted %d | open %d | success %.2f"), + TemplateStats->LaunchCount, + TemplateStats->CompletedRunCount, + TemplateStats->AbortedRunCount, + TemplateStats->OpenRunCount, + TemplateStats->OverallSuccessRate + ); + } + + FString BuildTemplateLaunchAnalyticsDetailFragment( + const FHyperTwistTrainingTemplateLaunchScopedStats* TemplateStats + ) + { + if (TemplateStats == nullptr || !TemplateStats->IsStructurallyValid()) + { + return TEXT("no retained per-template analytics are available for this template id yet"); + } + + 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"), + TemplateStats->AverageRecommendedCaseCount, + TemplateStats->AverageFocusCaseCount, + TemplateStats->AverageCompletedAttemptCount, + TemplateStats->TotalSuccessCount, + TemplateStats->TotalFailureCount, + TemplateStats->TotalTimeoutCount, + TemplateStats->TotalDNFCount, + TemplateStats->AverageMistakesPerOutcomeRun, + TemplateStats->ReviewBackedLaunchCount, + TemplateStats->CoachReviewLaunchCount, + TemplateStats->ShortReviewSetLaunchCount, + TemplateStats->LastLaunchAtUtc.IsEmpty() + ? TEXT("n/a") + : *TemplateStats->LastLaunchAtUtc, + TemplateStats->LastCompletedAtUtc.IsEmpty() + ? TEXT("n/a") + : *TemplateStats->LastCompletedAtUtc + ); + } + struct FClosureRecoveryDashboardBias { int32 ContinuationBias = 0; @@ -6992,6 +7042,34 @@ const FHyperTwistTrainingSessionTemplate* UHyperTwistCoachDashboardWidget::FindP return PreferredScore > 0 ? PreferredTemplate : nullptr; } +const FHyperTwistTrainingTemplateLaunchScopedStats* +UHyperTwistCoachDashboardWidget::FindCachedTemplateLaunchScopedStats( + const FString& TemplateId, + const FString& UserId +) const +{ + if (TemplateId.IsEmpty() + || CachedTemplateLaunchScopedStats.Num() <= 0) + { + return nullptr; + } + + if (!UserId.IsEmpty() + && !CachedTemplateLaunchAnalyticsUserId.IsEmpty() + && UserId != CachedTemplateLaunchAnalyticsUserId) + { + return nullptr; + } + + return CachedTemplateLaunchScopedStats.FindByPredicate( + [&TemplateId, &UserId](const FHyperTwistTrainingTemplateLaunchScopedStats& Candidate) + { + return Candidate.TemplateId == TemplateId + && (UserId.IsEmpty() || Candidate.UserId.IsEmpty() || Candidate.UserId == UserId); + } + ); +} + void UHyperTwistCoachDashboardWidget::CaptureActiveGuidanceLaunchContext( const FString& SessionId, const FHyperTwistTrainingDeck& Deck, @@ -9945,6 +10023,8 @@ void UHyperTwistCoachDashboardWidget::SyncTemplateLaunchHistoryFromRepository() ? TemplateLaunchHistoryEntries[SelectedTemplateLaunchHistoryIndex].SourceSessionId : FString(); + CachedTemplateLaunchAnalyticsUserId.Reset(); + CachedTemplateLaunchScopedStats.Reset(); TemplateLaunchHistoryEntries.Reset(); SelectedTemplateLaunchHistoryIndex = INDEX_NONE; @@ -9978,6 +10058,12 @@ void UHyperTwistCoachDashboardWidget::SyncTemplateLaunchHistoryFromRepository() const FHyperTwistTrainingRepositoryState RepositoryState = UHyperTwistTrainingRuntimeLibrary::GetTrainingRepositoryState(this); + CachedTemplateLaunchAnalyticsUserId = HistoryUserId; + CachedTemplateLaunchScopedStats = + UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser( + RepositoryState, + HistoryUserId, + FString()); const TArray UserRunRecords = UHyperTwistTrainingRepositoryLibrary::ListRunRecordsForDeck( RepositoryState, @@ -11211,6 +11297,17 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() bHasActiveTemplateLaunchForDisplayedRun ? &ActiveTemplateLaunchProvenance : (bHasDisplayedRetainedTemplateLaunch ? &RetainedTemplateLaunchProvenance : nullptr); + const FString DisplayedTemplateLaunchUserId = DisplayedTemplateLaunch != nullptr + ? (DisplayedTemplateLaunch->UserId.IsEmpty() + ? (bHasCurrentRunRecap ? CachedRunState.Session.UserId : RetainedRunRecapUserId) + : DisplayedTemplateLaunch->UserId) + : FString(); + const FHyperTwistTrainingTemplateLaunchScopedStats* DisplayedTemplateLaunchStats = + DisplayedTemplateLaunch != nullptr + ? FindCachedTemplateLaunchScopedStats( + DisplayedTemplateLaunch->TemplateId, + DisplayedTemplateLaunchUserId) + : nullptr; const bool bDisplayedTemplateLaunchUsesReviewLane = DisplayedTemplateLaunch != nullptr && (DisplayedTemplateLaunch->bPreferReviewRun @@ -11273,9 +11370,18 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() ? PreferredTemplateLaunch->TemplateId : PreferredTemplateLaunch->Title) : TEXT("n/a"); + const FString PreferredTemplateLaunchUserId = bHasPreferredTemplateLaunch + ? (PreferredTemplateLaunch->UserId.IsEmpty() ? DefaultUserId : PreferredTemplateLaunch->UserId) + : FString(); const FString PreferredTemplateLaunchFocusPreview = bHasPreferredTemplateLaunch ? BuildCaseIdPreview(PreferredTemplateLaunch->FocusCaseIds) : TEXT("none"); + const FHyperTwistTrainingTemplateLaunchScopedStats* PreferredTemplateLaunchStats = + bHasPreferredTemplateLaunch + ? FindCachedTemplateLaunchScopedStats( + PreferredTemplateLaunch->TemplateId, + PreferredTemplateLaunchUserId) + : nullptr; const bool bCanLaunchPreferredTemplate = !bHasLiveAttemptTimer && !bHasActiveRun && bHasPreferredTemplateLaunch; FString TemplateLaunchStatusLine; @@ -11290,27 +11396,33 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() else if (bHasLiveAttemptTimer) { TemplateLaunchStatusLine = FString::Printf( - TEXT("Template launch: %s is ready, but a live attempt is active."), - *PreferredTemplateLaunchTitle + TEXT("Template launch: %s is ready, but a live attempt is active. Past outcomes: %s."), + *PreferredTemplateLaunchTitle, + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsOutcomeFragment( + PreferredTemplateLaunchStats) ); } else if (bHasActiveRun) { TemplateLaunchStatusLine = FString::Printf( - TEXT("Template launch: %s is ready, but clear or complete the active run first."), - *PreferredTemplateLaunchTitle + TEXT("Template launch: %s is ready, but clear or complete the active run first. Past outcomes: %s."), + *PreferredTemplateLaunchTitle, + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsOutcomeFragment( + PreferredTemplateLaunchStats) ); } else { TemplateLaunchStatusLine = FString::Printf( - TEXT("Template launch: %s is ready on %s. Eligible %d of %d active templates."), + TEXT("Template launch: %s is ready on %s. Eligible %d of %d active templates. Past outcomes: %s."), *PreferredTemplateLaunchTitle, PreferredTemplateLaunch->FocusDeckId.IsEmpty() ? TEXT("n/a") : *PreferredTemplateLaunch->FocusDeckId, EligibleTemplateLaunchCount, - CachedActiveTrainingSessionTemplates.Num() + CachedActiveTrainingSessionTemplates.Num(), + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsOutcomeFragment( + PreferredTemplateLaunchStats) ); } FString TemplateLaunchDetailLine; @@ -11322,7 +11434,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() else { TemplateLaunchDetailLine = FString::Printf( - TEXT("Template: %s | id: %s | kind: %s | mode: %s | selection: %s | recommended %d | focus cases: %s | guidance: %s | method: %s | user: %s | source plan: %s | review-backed: %s | short set: %s"), + TEXT("Template: %s | id: %s | kind: %s | mode: %s | selection: %s | recommended %d | focus cases: %s | guidance: %s | method: %s | user: %s | source plan: %s | review-backed: %s | short set: %s | Past detail: %s"), *PreferredTemplateLaunchTitle, PreferredTemplateLaunch->TemplateId.IsEmpty() ? TEXT("n/a") @@ -11348,7 +11460,9 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() ? TEXT("none") : *PreferredTemplateLaunch->SourceReviewPlanId, bPreferredTemplateLaunchUsesReviewLane ? TEXT("yes") : TEXT("no"), - PreferredTemplateLaunch->bPreferShortReviewSet ? TEXT("yes") : TEXT("no") + PreferredTemplateLaunch->bPreferShortReviewSet ? TEXT("yes") : TEXT("no"), + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsDetailFragment( + PreferredTemplateLaunchStats) ); } const FHyperTwistCoachDashboardTemplateLaunchHistoryEntry* SelectedTemplateLaunchHistoryEntry = @@ -11356,10 +11470,16 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() && SelectedTemplateLaunchHistoryIndex < TemplateLaunchHistoryEntries.Num() ? &TemplateLaunchHistoryEntries[SelectedTemplateLaunchHistoryIndex] : nullptr; + const FHyperTwistTrainingTemplateLaunchScopedStats* SelectedTemplateLaunchHistoryStats = + SelectedTemplateLaunchHistoryEntry != nullptr + ? FindCachedTemplateLaunchScopedStats( + SelectedTemplateLaunchHistoryEntry->TemplateId, + SelectedTemplateLaunchHistoryEntry->UserId) + : nullptr; const FString TemplateLaunchHistoryStatusLine = SelectedTemplateLaunchHistoryEntry != nullptr ? FString::Printf( - TEXT("Template launch history: %d entries | selected %d/%d | Template: %s | State: %s | Completed: %s"), + TEXT("Template launch history: %d entries | selected %d/%d | Template: %s | State: %s | Completed: %s | Aggregate: %s"), TemplateLaunchHistoryEntries.Num(), SelectedTemplateLaunchHistoryIndex + 1, TemplateLaunchHistoryEntries.Num(), @@ -11370,12 +11490,14 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() SelectedTemplateLaunchHistoryEntry->SessionState), SelectedTemplateLaunchHistoryEntry->CompletedAtUtc.IsEmpty() ? TEXT("n/a") - : *SelectedTemplateLaunchHistoryEntry->CompletedAtUtc) + : *SelectedTemplateLaunchHistoryEntry->CompletedAtUtc, + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsOutcomeFragment( + SelectedTemplateLaunchHistoryStats)) : TEXT("Template launch history: no completed template-backed runs retained yet."); const FString TemplateLaunchHistoryDetailLine = SelectedTemplateLaunchHistoryEntry != nullptr ? FString::Printf( - TEXT("Title: %s | kind: %s | mode: %s | selection: %s | deck: %s | user: %s | source plan: %s | recommended %d | focus %d | attempts %d | success %.2f | mistakes %d | review-backed: %s | coach-review: %s | short set: %s"), + TEXT("Title: %s | kind: %s | mode: %s | selection: %s | deck: %s | user: %s | source plan: %s | recommended %d | focus %d | attempts %d | success %.2f | mistakes %d | review-backed: %s | coach-review: %s | short set: %s | Aggregate detail: %s"), SelectedTemplateLaunchHistoryEntry->TemplateTitle.IsEmpty() ? TEXT("n/a") : *SelectedTemplateLaunchHistoryEntry->TemplateTitle, @@ -11401,7 +11523,9 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() SelectedTemplateLaunchHistoryEntry->TotalMistakes, SelectedTemplateLaunchHistoryEntry->bPreferReviewRun ? TEXT("yes") : TEXT("no"), SelectedTemplateLaunchHistoryEntry->bCoachReview ? TEXT("yes") : TEXT("no"), - SelectedTemplateLaunchHistoryEntry->bPreferShortReviewSet ? TEXT("yes") : TEXT("no")) + SelectedTemplateLaunchHistoryEntry->bPreferShortReviewSet ? TEXT("yes") : TEXT("no"), + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsDetailFragment( + SelectedTemplateLaunchHistoryStats)) : TEXT("Template launch history detail: completed runs started through the dashboard template lane will accumulate here with template id, template kind, and source review-plan provenance."); const bool bCanUseCoachHandoff = !bHasLiveAttemptTimer && !bHasActiveRun; FString CoachHandoffStatusLine; @@ -15416,7 +15540,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() RunRecapDetailTextBlock->SetText(FText::FromString( DisplayedTemplateLaunch != nullptr ? FString::Printf( - TEXT("Attempts %d/%d | Success %d | Failure %d | Timeout %d | DNF %d | Aborted %d | Success rate %.2f | Best %d ms | Avg %d ms | Avg raw %d ms | Avg inspection %d ms | Mistakes %d | Last case %s | Template title %s | Template mode %s | Template selection %s | Recommended %d | Focus %d | Review-backed %s"), + TEXT("Attempts %d/%d | Success %d | Failure %d | Timeout %d | DNF %d | Aborted %d | Success rate %.2f | Best %d ms | Avg %d ms | Avg raw %d ms | Avg inspection %d ms | Mistakes %d | Last case %s | Template title %s | Template mode %s | Template selection %s | Recommended %d | Focus %d | Review-backed %s | Template aggregate %s | Template detail %s"), DisplayedRunRecap->CompletedAttemptCount, DisplayedRunRecap->AttemptCount, DisplayedRunRecap->SuccessCount, @@ -15440,7 +15564,11 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() DisplayedTemplateLaunch->SuggestedSelectionPolicy), DisplayedTemplateLaunch->RecommendedCaseCount, DisplayedTemplateLaunch->FocusCaseIds.Num(), - bDisplayedTemplateLaunchUsesReviewLane ? TEXT("yes") : TEXT("no")) + bDisplayedTemplateLaunchUsesReviewLane ? TEXT("yes") : TEXT("no"), + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsOutcomeFragment( + DisplayedTemplateLaunchStats), + *HyperTwistCoachDashboardWidgetInternal::BuildTemplateLaunchAnalyticsDetailFragment( + DisplayedTemplateLaunchStats)) : FString::Printf( TEXT("Attempts %d/%d | Success %d | Failure %d | Timeout %d | DNF %d | Aborted %d | Success rate %.2f | Best %d ms | Avg %d ms | Avg raw %d ms | Avg inspection %d ms | Mistakes %d | Last case %s"), DisplayedRunRecap->CompletedAttemptCount, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index 6275961..68738eb 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -9102,6 +9102,235 @@ FHyperTwistTrainingDeckScopedStats UHyperTwistTrainingRepositoryLibrary::DeriveD return Stats; } +FHyperTwistTrainingTemplateLaunchScopedStats UHyperTwistTrainingRepositoryLibrary::DeriveTemplateLaunchScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& TemplateId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingTemplateLaunchScopedStats ScopedStats; + if (UserId.IsEmpty() || TemplateId.IsEmpty()) + { + return ScopedStats; + } + + const TArray TemplateScopedStats = + ListTemplateLaunchScopedStatsForUser( + RepositoryState, + UserId, + ReferenceUtc + ); + const FHyperTwistTrainingTemplateLaunchScopedStats* ExistingStats = + TemplateScopedStats.FindByPredicate( + [&TemplateId](const FHyperTwistTrainingTemplateLaunchScopedStats& Candidate) + { + return Candidate.TemplateId == TemplateId; + } + ); + return ExistingStats != nullptr ? *ExistingStats : ScopedStats; +} + +TArray +UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc +) +{ + TArray ScopedStats; + if (UserId.IsEmpty()) + { + return ScopedStats; + } + + struct FTemplateLaunchScopedStatsAccumulator + { + FHyperTwistTrainingTemplateLaunchScopedStats Stats; + int64 RecommendedCaseAccumulator = 0; + int64 FocusCaseAccumulator = 0; + int64 CompletedAttemptAccumulator = 0; + int64 MistakeAccumulator = 0; + int32 OutcomeRunSampleCount = 0; + }; + + const FString ResolvedReferenceUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + const TArray RunRecords = + ListRunRecordsForDeck(RepositoryState, UserId, FString()); + TArray Accumulators; + TMap AccumulatorIndexByTemplateId; + for (const FHyperTwistTrainingRunRecord& RunRecord : RunRecords) + { + if (!RunRecord.IsStructurallyValid() + || !RunRecord.TemplateLaunchProvenance.IsStructurallyValid()) + { + continue; + } + + const FHyperTwistTrainingTemplateLaunchProvenance& Provenance = + RunRecord.TemplateLaunchProvenance; + const FString ScopedUserId = !Provenance.UserId.IsEmpty() + ? Provenance.UserId + : RunRecord.Session.UserId; + if (ScopedUserId != UserId) + { + continue; + } + + int32* ExistingIndex = AccumulatorIndexByTemplateId.Find(Provenance.TemplateId); + if (ExistingIndex == nullptr) + { + FTemplateLaunchScopedStatsAccumulator Accumulator; + Accumulator.Stats.UserId = UserId; + Accumulator.Stats.TemplateId = Provenance.TemplateId; + Accumulator.Stats.ReferenceUtc = ResolvedReferenceUtc; + Accumulator.Stats.SourceTemplateId = Provenance.SourceTemplateId; + Accumulator.Stats.Title = Provenance.Title; + Accumulator.Stats.FocusDeckId = Provenance.FocusDeckId; + Accumulator.Stats.SourceReviewPlanId = Provenance.SourceReviewPlanId; + Accumulator.Stats.TemplateKind = Provenance.TemplateKind; + Accumulator.Stats.SuggestedMode = Provenance.SuggestedMode; + Accumulator.Stats.SuggestedSelectionPolicy = Provenance.SuggestedSelectionPolicy; + const int32 AddedIndex = Accumulators.Add(MoveTemp(Accumulator)); + AccumulatorIndexByTemplateId.Add(Provenance.TemplateId, AddedIndex); + ExistingIndex = AccumulatorIndexByTemplateId.Find(Provenance.TemplateId); + } + + FTemplateLaunchScopedStatsAccumulator& Accumulator = Accumulators[*ExistingIndex]; + const FString LaunchAtUtc = !RunRecord.Session.StartedAtUtc.IsEmpty() + ? RunRecord.Session.StartedAtUtc + : RunRecord.LastUpdatedAtUtc; + if (Accumulator.Stats.LastLaunchAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + LaunchAtUtc, + Accumulator.Stats.LastLaunchAtUtc) > 0) + { + Accumulator.Stats.SourceTemplateId = Provenance.SourceTemplateId; + Accumulator.Stats.Title = Provenance.Title; + Accumulator.Stats.FocusDeckId = Provenance.FocusDeckId; + Accumulator.Stats.SourceReviewPlanId = Provenance.SourceReviewPlanId; + Accumulator.Stats.TemplateKind = Provenance.TemplateKind; + Accumulator.Stats.SuggestedMode = Provenance.SuggestedMode; + Accumulator.Stats.SuggestedSelectionPolicy = Provenance.SuggestedSelectionPolicy; + Accumulator.Stats.LastLaunchAtUtc = LaunchAtUtc; + Accumulator.Stats.LastSessionId = RunRecord.Session.TrainingSessionId; + } + + Accumulator.Stats.LaunchCount += 1; + Accumulator.Stats.TotalAttemptCount += RunRecord.Summary.AttemptCount; + Accumulator.Stats.TotalCompletedAttemptCount += RunRecord.Summary.CompletedAttemptCount; + Accumulator.Stats.TotalSuccessCount += RunRecord.Summary.SuccessCount; + Accumulator.Stats.TotalFailureCount += RunRecord.Summary.FailureCount; + Accumulator.Stats.TotalTimeoutCount += RunRecord.Summary.TimeoutCount; + Accumulator.Stats.TotalDNFCount += RunRecord.Summary.DNFCount; + Accumulator.Stats.TotalMistakes += RunRecord.Summary.TotalMistakes; + Accumulator.RecommendedCaseAccumulator += FMath::Max(Provenance.RecommendedCaseCount, 0); + Accumulator.FocusCaseAccumulator += Provenance.FocusCaseIds.Num(); + + const bool bReviewBackedLaunch = + Provenance.bPreferReviewRun + || Provenance.TemplateKind == EHyperTwistTrainingSessionTemplateKind::ReviewPlan + || Provenance.TemplateKind == EHyperTwistTrainingSessionTemplateKind::Coach; + if (bReviewBackedLaunch) + { + Accumulator.Stats.ReviewBackedLaunchCount += 1; + } + if (Provenance.bCoachReview) + { + Accumulator.Stats.CoachReviewLaunchCount += 1; + } + if (Provenance.bPreferShortReviewSet) + { + Accumulator.Stats.ShortReviewSetLaunchCount += 1; + } + + if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Completed) + { + Accumulator.Stats.CompletedRunCount += 1; + } + else if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Aborted) + { + Accumulator.Stats.AbortedRunCount += 1; + } + else + { + Accumulator.Stats.OpenRunCount += 1; + } + + if (RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Completed + || RunRecord.Session.SessionState == EHyperTwistTrainingSessionState::Aborted) + { + const FString CompletedAtUtc = !RunRecord.Summary.LastCompletedAtUtc.IsEmpty() + ? RunRecord.Summary.LastCompletedAtUtc + : RunRecord.Session.EndedAtUtc; + if (!CompletedAtUtc.IsEmpty() + && (Accumulator.Stats.LastCompletedAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + CompletedAtUtc, + Accumulator.Stats.LastCompletedAtUtc) > 0)) + { + Accumulator.Stats.LastCompletedAtUtc = CompletedAtUtc; + } + + Accumulator.CompletedAttemptAccumulator += RunRecord.Summary.CompletedAttemptCount; + Accumulator.MistakeAccumulator += RunRecord.Summary.TotalMistakes; + Accumulator.OutcomeRunSampleCount += 1; + } + } + + for (FTemplateLaunchScopedStatsAccumulator& Accumulator : Accumulators) + { + Accumulator.Stats.AverageRecommendedCaseCount = Accumulator.Stats.LaunchCount > 0 + ? static_cast( + Accumulator.RecommendedCaseAccumulator / Accumulator.Stats.LaunchCount) + : 0; + Accumulator.Stats.AverageFocusCaseCount = Accumulator.Stats.LaunchCount > 0 + ? static_cast( + Accumulator.FocusCaseAccumulator / Accumulator.Stats.LaunchCount) + : 0; + Accumulator.Stats.AverageCompletedAttemptCount = Accumulator.OutcomeRunSampleCount > 0 + ? static_cast( + Accumulator.CompletedAttemptAccumulator / Accumulator.OutcomeRunSampleCount) + : 0; + Accumulator.Stats.OverallSuccessRate = Accumulator.Stats.TotalCompletedAttemptCount > 0 + ? static_cast(Accumulator.Stats.TotalSuccessCount) + / static_cast(Accumulator.Stats.TotalCompletedAttemptCount) + : 0.0f; + Accumulator.Stats.AverageMistakesPerOutcomeRun = Accumulator.OutcomeRunSampleCount > 0 + ? static_cast(Accumulator.MistakeAccumulator) + / static_cast(Accumulator.OutcomeRunSampleCount) + : 0.0f; + if (Accumulator.Stats.IsStructurallyValid()) + { + ScopedStats.Add(Accumulator.Stats); + } + } + + ScopedStats.Sort([]( + const FHyperTwistTrainingTemplateLaunchScopedStats& Left, + const FHyperTwistTrainingTemplateLaunchScopedStats& Right + ) + { + const int32 LaunchAtCompare = HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + Left.LastLaunchAtUtc, + Right.LastLaunchAtUtc + ); + if (LaunchAtCompare != 0) + { + return LaunchAtCompare > 0; + } + if (Left.LaunchCount != Right.LaunchCount) + { + return Left.LaunchCount > Right.LaunchCount; + } + + return Left.TemplateId < Right.TemplateId; + }); + + return ScopedStats; +} + FHyperTwistTrainingLearnerDeckStateSummary UHyperTwistTrainingRepositoryLibrary::DeriveLearnerDeckStateSummary( const FHyperTwistTrainingRepositoryState& RepositoryState, const FString& UserId, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index 56e8bff..1c4d50d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -1583,6 +1583,10 @@ private: const FHyperTwistTrainingTimingPolicy* FindActiveTimingPolicy() const; const FHyperTwistTrainingDeck* GetActiveGuidancePreviewDeck() const; const FHyperTwistCoachBrief* GetActiveGuidancePreviewBrief() const; + const FHyperTwistTrainingTemplateLaunchScopedStats* FindCachedTemplateLaunchScopedStats( + const FString& TemplateId, + const FString& UserId + ) const; const FHyperTwistTrainingSessionTemplate* FindPreferredDashboardTemplateLaunch() const; FString ResolveDashboardHandoffStartActionSourceLabel( EHyperTwistTrainingCoachHandoffKind HandoffKind) const; @@ -1721,6 +1725,8 @@ private: FHyperTwistCoachBrief RetainedGuidanceComparisonBrief; FHyperTwistTrainingDeck RetainedGuidanceComparisonDeck; FString RetainedGuidanceComparisonSelectedCaseId; + FString CachedTemplateLaunchAnalyticsUserId; + TArray CachedTemplateLaunchScopedStats; TArray GuidanceDecisionHistoryEntries; TArray HandoffHistoryEntries; TArray TemplateLaunchHistoryEntries; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h index bf09957..566c5a7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h @@ -270,6 +270,21 @@ public: const FString& ReferenceUtc ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingTemplateLaunchScopedStats DeriveTemplateLaunchScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& TemplateId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ListTemplateLaunchScopedStatsForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") static FHyperTwistTrainingLearnerDeckStateSummary DeriveLearnerDeckStateSummary( const FHyperTwistTrainingRepositoryState& RepositoryState, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index c55553e..3699cac 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -1974,6 +1974,115 @@ struct FHyperTwistTrainingDeckScopedStats } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingTemplateLaunchScopedStats +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TemplateId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourceTemplateId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FocusDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourceReviewPlanId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSessionTemplateKind TemplateKind = + EHyperTwistTrainingSessionTemplateKind::Recovery; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode SuggestedMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSelectionPolicy SuggestedSelectionPolicy = + EHyperTwistTrainingSelectionPolicy::Weighted; + + 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 ReviewBackedLaunchCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CoachReviewLaunchCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ShortReviewSetLaunchCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalCompletedAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalFailureCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeoutCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalDNFCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalMistakes = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRecommendedCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageFocusCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageCompletedAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float OverallSuccessRate = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float AverageMistakesPerOutcomeRun = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastLaunchAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastCompletedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastSessionId; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !TemplateId.IsEmpty() && LaunchCount > 0; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingLearnerDeckStateSummary { diff --git a/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SCOPED_ANALYTICS_PACKET_2026-05-06.md b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SCOPED_ANALYTICS_PACKET_2026-05-06.md new file mode 100644 index 0000000..6d35776 --- /dev/null +++ b/docs/arch/HYPERTWIST_PHASE4_TEMPLATE_LAUNCH_SCOPED_ANALYTICS_PACKET_2026-05-06.md @@ -0,0 +1,102 @@ +# HyperTwist Phase 4 template launch scoped analytics packet + +Created on `2026-05-06` + +Status: + +- first-party HyperTwist packet +- bounded Phase `4` repository-backed template analytics slice + +## Purpose + +This packet turns persisted template-launch provenance into repo-backed per-template outcome analytics and surfaces those aggregates on the first-party dashboard. + +The open tasks were: + +- derive reusable per-template launch/outcome stats from stored run records +- stop relying on ad hoc recap inspection to judge whether a template is paying off +- surface those stats on the dashboard where operators launch and review template-backed runs + +It is not: + +- a template ranking rewrite packet +- a new template editor or browser packet +- a coach recommendation algorithm rewrite packet +- a broader analytics export packet + +## Scope + +Bounded lane: + +- add a persisted `FHyperTwistTrainingTemplateLaunchScopedStats` summary type +- add repository helpers for deriving and listing per-template launch stats for a user +- cache those repo-backed stats on the dashboard during template-history sync +- append aggregate template outcome trends to the preferred active template launch section +- append the same aggregate trends to template-launch history detail and template-backed run recap detail + +Out of scope: + +- changing which template wins dashboard launch priority +- changing review-plan or method-drill derivation semantics +- introducing a new dashboard page or analytics screen +- feeding template outcome stats directly into coach handoff scoring rules yet + +## Why this was the right next packet + +The previous packet made template provenance durable in run history. + +That still left one obvious gap: + +- the repository now knew which template launched each run +- but there was still no first-party aggregate view of how a template was actually performing across launches +- and dashboard operators still had to infer template quality from one recap at a time + +So the next honest move was: + +- derive per-template scoped stats from the stored run records +- keep the aggregation repo-backed and reusable +- and surface the results on the existing dashboard template launch, history, and recap lanes + +## What landed + +Primary code changes: + +- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h` + - added `FHyperTwistTrainingTemplateLaunchScopedStats` +- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h` + - added repository APIs for deriving and listing template launch scoped stats +- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp` + - aggregates per-template launch counts, outcome counts, aggregate success/failure totals, average launch sizing, average mistakes, and latest launch/completion timestamps from stored run records +- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h` + - added cached template-launch analytics state plus a lookup helper +- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp` + - caches template launch scoped stats when syncing repo-backed template history + - appends aggregate template outcome lines to the preferred active template launch section + - appends the same aggregate context to template launch history detail + - appends aggregate template outcome context to template-backed run recap detail + +## Product effect + +Template quality is now visible without manual cross-referencing: + +- the dashboard launch lane now shows how the preferred active template has performed across prior launches +- template history navigation now carries aggregate per-template outcome context, not just one run snapshot +- template-backed run recap now includes the broader historical template signal alongside the individual run outcome +- later coach weighting or analytics work can reuse the same repo-backed template stats instead of re-scanning raw run records ad hoc + +## Acceptance criteria + +- repository can derive per-template launch scoped stats from stored run records +- dashboard caches repo-backed template stats during template history sync +- preferred active template launch detail shows aggregate outcome trends when available +- template-backed run history and run recap surfaces show aggregate template outcome context +- full product build succeeds + +## Validation checklist + +1. build `UnrealHyperTwist.sln` / `UnrealHyperTwistEditor` +2. confirm repository template stats populate for users with template-backed run history +3. confirm dashboard `[Template Launch]` and `[Template Launch History]` sections show aggregate template outcome context +4. confirm template-backed run recap shows the aggregate template signal beside the single-run outcome + +That is the packet.