Add template launch scoped analytics

This commit is contained in:
axiomlogicnexus 2026-05-06 23:56:30 +02:00
parent c17bfca008
commit 2d20d0ba60
6 changed files with 603 additions and 14 deletions

View file

@ -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<FHyperTwistTrainingRunRecord> 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,

View file

@ -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<FHyperTwistTrainingTemplateLaunchScopedStats> TemplateScopedStats =
ListTemplateLaunchScopedStatsForUser(
RepositoryState,
UserId,
ReferenceUtc
);
const FHyperTwistTrainingTemplateLaunchScopedStats* ExistingStats =
TemplateScopedStats.FindByPredicate(
[&TemplateId](const FHyperTwistTrainingTemplateLaunchScopedStats& Candidate)
{
return Candidate.TemplateId == TemplateId;
}
);
return ExistingStats != nullptr ? *ExistingStats : ScopedStats;
}
TArray<FHyperTwistTrainingTemplateLaunchScopedStats>
UHyperTwistTrainingRepositoryLibrary::ListTemplateLaunchScopedStatsForUser(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& UserId,
const FString& ReferenceUtc
)
{
TArray<FHyperTwistTrainingTemplateLaunchScopedStats> 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<FHyperTwistTrainingRunRecord> RunRecords =
ListRunRecordsForDeck(RepositoryState, UserId, FString());
TArray<FTemplateLaunchScopedStatsAccumulator> Accumulators;
TMap<FString, int32> 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<int32>(
Accumulator.RecommendedCaseAccumulator / Accumulator.Stats.LaunchCount)
: 0;
Accumulator.Stats.AverageFocusCaseCount = Accumulator.Stats.LaunchCount > 0
? static_cast<int32>(
Accumulator.FocusCaseAccumulator / Accumulator.Stats.LaunchCount)
: 0;
Accumulator.Stats.AverageCompletedAttemptCount = Accumulator.OutcomeRunSampleCount > 0
? static_cast<int32>(
Accumulator.CompletedAttemptAccumulator / Accumulator.OutcomeRunSampleCount)
: 0;
Accumulator.Stats.OverallSuccessRate = Accumulator.Stats.TotalCompletedAttemptCount > 0
? static_cast<float>(Accumulator.Stats.TotalSuccessCount)
/ static_cast<float>(Accumulator.Stats.TotalCompletedAttemptCount)
: 0.0f;
Accumulator.Stats.AverageMistakesPerOutcomeRun = Accumulator.OutcomeRunSampleCount > 0
? static_cast<float>(Accumulator.MistakeAccumulator)
/ static_cast<float>(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,

View file

@ -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<FHyperTwistTrainingTemplateLaunchScopedStats> CachedTemplateLaunchScopedStats;
TArray<FHyperTwistCoachDashboardDecisionHistoryEntry> GuidanceDecisionHistoryEntries;
TArray<FHyperTwistCoachDashboardHandoffHistoryEntry> HandoffHistoryEntries;
TArray<FHyperTwistCoachDashboardTemplateLaunchHistoryEntry> TemplateLaunchHistoryEntries;

View file

@ -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<FHyperTwistTrainingTemplateLaunchScopedStats> ListTemplateLaunchScopedStatsForUser(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& UserId,
const FString& ReferenceUtc
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository")
static FHyperTwistTrainingLearnerDeckStateSummary DeriveLearnerDeckStateSummary(
const FHyperTwistTrainingRepositoryState& RepositoryState,

View file

@ -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
{

View file

@ -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.