Split template launch analytics by selector lane

This commit is contained in:
axiomlogicnexus 2026-05-07 01:13:57 +02:00
parent bc6d234a14
commit 38069d07ad
4 changed files with 355 additions and 4 deletions

View file

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

View file

@ -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<float>(Accumulator.MistakeAccumulator)
/ static_cast<float>(Accumulator.OutcomeRunSampleCount)
: 0.0f;
Accumulator.DashboardPrimarySelectionAccumulator.Stats.OverallSuccessRate =
Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalCompletedAttemptCount > 0
? static_cast<float>(
Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalSuccessCount)
/ static_cast<float>(
Accumulator.DashboardPrimarySelectionAccumulator.Stats.TotalCompletedAttemptCount)
: 0.0f;
Accumulator.DashboardPrimarySelectionAccumulator.Stats.AverageMistakesPerOutcomeRun =
Accumulator.DashboardPrimarySelectionAccumulator.OutcomeRunSampleCount > 0
? static_cast<float>(Accumulator.DashboardPrimarySelectionAccumulator.MistakeAccumulator)
/ static_cast<float>(
Accumulator.DashboardPrimarySelectionAccumulator.OutcomeRunSampleCount)
: 0.0f;
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.OverallSuccessRate =
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalCompletedAttemptCount > 0
? static_cast<float>(
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalSuccessCount)
/ static_cast<float>(
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.TotalCompletedAttemptCount)
: 0.0f;
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.Stats.AverageMistakesPerOutcomeRun =
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.OutcomeRunSampleCount > 0
? static_cast<float>(
Accumulator.DashboardAnalyticsTieBreakSelectionAccumulator.MistakeAccumulator)
/ static_cast<float>(
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);

View file

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

View file

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