Split HyperTwist review repository family

This commit is contained in:
axiomlogicnexus 2026-06-29 23:27:28 +00:00
parent e9827c7e66
commit c6a02c9147
5 changed files with 923 additions and 804 deletions

View file

@ -1,5 +1,6 @@
#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h"
#include "HyperTwistTrainingRepositoryLibraryInternal.h"
#include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingKnowledgeLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingLibrary.h"
@ -31973,807 +31974,3 @@ FHyperTwistTrainingCaseRecommendation UHyperTwistTrainingRepositoryLibrary::Make
ReferenceUtc
);
}
FHyperTwistTrainingReviewPolicy UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy(
EHyperTwistTrainingDeliveryMode DeliveryMode
)
{
FHyperTwistTrainingReviewPolicy ReviewPolicy;
ReviewPolicy.DeliveryMode = DeliveryMode;
ReviewPolicy.MaxCases = 5;
ReviewPolicy.NewCaseBias = 0.0f;
ReviewPolicy.DueCaseBias = 0.0f;
ReviewPolicy.RecognitionBias = 0.0f;
ReviewPolicy.AccuracyBias = 0.0f;
ReviewPolicy.bRepeatFailuresImmediately = true;
ReviewPolicy.bPreferShortReviewSets = false;
switch (DeliveryMode)
{
case EHyperTwistTrainingDeliveryMode::VirtualCube:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::VirtualCubePractice;
ReviewPolicy.PolicyLabel = TEXT("virtual-cube-practice");
ReviewPolicy.MaxCases = 6;
ReviewPolicy.NewCaseBias = 80.0f;
ReviewPolicy.DueCaseBias = 95.0f;
ReviewPolicy.AccuracyBias = 20.0f;
break;
case EHyperTwistTrainingDeliveryMode::SmartDevice:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::SmartDeviceAccuracy;
ReviewPolicy.PolicyLabel = TEXT("smart-device-accuracy");
ReviewPolicy.MaxCases = 5;
ReviewPolicy.DueCaseBias = 115.0f;
ReviewPolicy.AccuracyBias = 90.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::RecognitionAssisted:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::RecognitionAssistedScan;
ReviewPolicy.PolicyLabel = TEXT("recognition-assisted-scan");
ReviewPolicy.MaxCases = 4;
ReviewPolicy.NewCaseBias = -20.0f;
ReviewPolicy.DueCaseBias = 105.0f;
ReviewPolicy.RecognitionBias = 130.0f;
ReviewPolicy.AccuracyBias = 45.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::CoachReviewed:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::CoachReview;
ReviewPolicy.PolicyLabel = TEXT("coach-review");
ReviewPolicy.MaxCases = 4;
ReviewPolicy.NewCaseBias = 35.0f;
ReviewPolicy.DueCaseBias = 90.0f;
ReviewPolicy.RecognitionBias = 30.0f;
ReviewPolicy.AccuracyBias = 110.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::Timer:
default:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::TimerBalanced;
ReviewPolicy.PolicyLabel = TEXT("timer-balanced");
ReviewPolicy.MaxCases = 5;
ReviewPolicy.NewCaseBias = -10.0f;
ReviewPolicy.DueCaseBias = 120.0f;
ReviewPolicy.AccuracyBias = 35.0f;
break;
}
return ReviewPolicy;
}
TArray<FHyperTwistTrainingCaseRecommendation> UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingDeck& Deck,
const FString& UserId,
const FString& ReferenceUtc,
int32 MaxRecommendations
)
{
TArray<FHyperTwistTrainingCaseRecommendation> Recommendations;
if (!Deck.IsStructurallyValid())
{
return Recommendations;
}
for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
{
if (!TrainingCase.IsStructurallyValid())
{
continue;
}
FHyperTwistTrainingCaseLearnerState LearnerState;
TryGetLearnerState(RepositoryState, UserId, Deck.DeckId, TrainingCase.CaseId, LearnerState);
FHyperTwistTrainingCaseRecommendation Recommendation = MakeCaseRecommendation(
TrainingCase,
LearnerState,
ReferenceUtc
);
if (Recommendation.IsStructurallyValid())
{
Recommendations.Add(Recommendation);
}
}
Recommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right)
{
if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore))
{
return Left.RecommendationScore > Right.RecommendationScore;
}
if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue))
{
return Left.DaysUntilDue < Right.DaysUntilDue;
}
return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId;
});
if (MaxRecommendations > 0 && Recommendations.Num() > MaxRecommendations)
{
Recommendations.SetNum(MaxRecommendations, EAllowShrinking::No);
}
return Recommendations;
}
TArray<FHyperTwistTrainingCaseRecommendation> UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations(
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
const FHyperTwistTrainingReviewPolicy& ReviewPolicy,
int32 MaxRecommendations
)
{
TArray<FHyperTwistTrainingCaseRecommendation> FilteredRecommendations;
FilteredRecommendations.Reserve(Recommendations.Num());
for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations)
{
if (!Recommendation.IsStructurallyValid())
{
continue;
}
FHyperTwistTrainingCaseRecommendation FilteredRecommendation = Recommendation;
float AdjustedScore = FilteredRecommendation.RecommendationScore;
if (FilteredRecommendation.bIsNewCase)
{
AdjustedScore += ReviewPolicy.NewCaseBias;
}
if (FilteredRecommendation.bIsDue)
{
AdjustedScore += ReviewPolicy.DueCaseBias;
}
if (FilteredRecommendation.TrainingCase.PromptKind == EHyperTwistTrainingPromptKind::Recognition
|| ReviewPolicy.DeliveryMode == EHyperTwistTrainingDeliveryMode::RecognitionAssisted)
{
AdjustedScore += ReviewPolicy.RecognitionBias;
}
if (FilteredRecommendation.bRequiresReviewFocus
|| FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Again
|| FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Hard
|| FilteredRecommendation.LearnerState.LapseCount > 0)
{
AdjustedScore += ReviewPolicy.AccuracyBias;
}
if (ReviewPolicy.bPreferShortReviewSets
&& !FilteredRecommendation.bIsDue
&& !FilteredRecommendation.bRequiresReviewFocus
&& !FilteredRecommendation.bIsNewCase)
{
AdjustedScore -= 55.0f;
}
FilteredRecommendation.RecommendationScore = AdjustedScore;
FilteredRecommendations.Add(FilteredRecommendation);
}
FilteredRecommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right)
{
if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore))
{
return Left.RecommendationScore > Right.RecommendationScore;
}
if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue))
{
return Left.DaysUntilDue < Right.DaysUntilDue;
}
return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId;
});
const int32 EffectiveMaxRecommendations = HyperTwistTrainingRepositoryLibraryInternal::ResolvePolicyCaseLimit(
ReviewPolicy,
MaxRecommendations
);
if (EffectiveMaxRecommendations > 0 && FilteredRecommendations.Num() > EffectiveMaxRecommendations)
{
FilteredRecommendations.SetNum(EffectiveMaxRecommendations, EAllowShrinking::No);
}
return FilteredRecommendations;
}
FHyperTwistTrainingDeck UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations(
const FHyperTwistTrainingDeck& SourceDeck,
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
int32 MaxCases
)
{
FHyperTwistTrainingDeck ReviewDeck = SourceDeck;
if (!SourceDeck.IsStructurallyValid())
{
return FHyperTwistTrainingDeck();
}
ReviewDeck.DeckId = SourceDeck.DeckId + TEXT("/review");
ReviewDeck.Title = SourceDeck.Title + TEXT(" Review");
ReviewDeck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Spaced;
ReviewDeck.Tags = SourceDeck.Tags;
ReviewDeck.Tags.AddUnique(TEXT("review"));
ReviewDeck.Tags.AddUnique(TEXT("recommendation-shaped"));
ReviewDeck.Cases.Reset();
const int32 EffectiveMaxCases = MaxCases > 0 ? MaxCases : Recommendations.Num();
for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations)
{
if (!Recommendation.IsStructurallyValid())
{
continue;
}
ReviewDeck.Cases.Add(Recommendation.TrainingCase);
if (ReviewDeck.Cases.Num() >= EffectiveMaxCases)
{
break;
}
}
if (ReviewDeck.Cases.Num() == 0)
{
return FHyperTwistTrainingDeck();
}
return ReviewDeck;
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::BuildReviewPlan(
const FHyperTwistTrainingDeck& SourceDeck,
const FHyperTwistTrainingDeck& ReviewDeck,
const FString& UserId,
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
const FHyperTwistTrainingReviewPolicy& ReviewPolicy,
const FString& PlanId,
const FString& ReferenceUtc,
EHyperTwistTrainingDeliveryMode DeliveryMode
)
{
FHyperTwistTrainingReviewPlanState ReviewPlan;
if (!SourceDeck.IsStructurallyValid() || !ReviewDeck.IsStructurallyValid() || UserId.IsEmpty())
{
return ReviewPlan;
}
ReviewPlan.PlanId = PlanId.IsEmpty() ? FString::Printf(TEXT("review_plan_%s"), *ReviewDeck.DeckId) : PlanId;
ReviewPlan.UserId = UserId;
ReviewPlan.SourceDeckId = SourceDeck.DeckId;
ReviewPlan.ReviewDeckId = ReviewDeck.DeckId;
ReviewPlan.ReferenceUtc = ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc;
ReviewPlan.DeliveryMode = DeliveryMode;
ReviewPlan.Policy = ReviewPolicy;
ReviewPlan.CreatedAtUtc = ReviewPlan.ReferenceUtc;
ReviewPlan.StartedAtUtc = ReviewPlan.ReferenceUtc;
for (const FHyperTwistTrainingCase& TrainingCase : ReviewDeck.Cases)
{
if (!TrainingCase.IsStructurallyValid())
{
continue;
}
const FHyperTwistTrainingCaseRecommendation* MatchingRecommendation = Recommendations.FindByPredicate(
[&TrainingCase](const FHyperTwistTrainingCaseRecommendation& Candidate)
{
return Candidate.TrainingCase.CaseId == TrainingCase.CaseId;
}
);
FHyperTwistTrainingReviewPlanEntry Entry;
Entry.CaseId = TrainingCase.CaseId;
Entry.PromptLabel = TrainingCase.PromptLabel;
Entry.RecommendationReason = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationReason : TEXT("review-case");
Entry.RecommendationScore = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationScore : 0.0f;
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending;
ReviewPlan.Entries.Add(Entry);
}
if (ReviewPlan.Entries.Num() > 0)
{
ReviewPlan.CurrentEntryIndex = 0;
ReviewPlan.Entries[0].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current;
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(ReviewPlan);
}
FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingReviewPlanState& ReviewPlan
)
{
FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState;
const FHyperTwistTrainingReviewPlanState NormalizedReviewPlan =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(ReviewPlan);
if (!NormalizedReviewPlan.IsStructurallyValid())
{
return UpdatedState;
}
const int32 ExistingIndex = UpdatedState.ReviewPlans.IndexOfByPredicate(
[&NormalizedReviewPlan](const FHyperTwistTrainingReviewPlanState& ExistingPlan)
{
return ExistingPlan.PlanId == NormalizedReviewPlan.PlanId;
}
);
if (ExistingIndex >= 0)
{
UpdatedState.ReviewPlans[ExistingIndex] = NormalizedReviewPlan;
}
else
{
UpdatedState.ReviewPlans.Add(NormalizedReviewPlan);
}
return UpdatedState;
}
bool UHyperTwistTrainingRepositoryLibrary::TryGetReviewPlan(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& PlanId,
FHyperTwistTrainingReviewPlanState& OutReviewPlan
)
{
OutReviewPlan = FHyperTwistTrainingReviewPlanState();
if (PlanId.IsEmpty())
{
return false;
}
const FHyperTwistTrainingReviewPlanState* ExistingPlan = RepositoryState.ReviewPlans.FindByPredicate(
[&PlanId](const FHyperTwistTrainingReviewPlanState& Candidate)
{
return Candidate.PlanId == PlanId;
}
);
if (ExistingPlan == nullptr)
{
return false;
}
OutReviewPlan = HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(*ExistingPlan);
return OutReviewPlan.IsStructurallyValid();
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::UpdateReviewPlanAfterAttempt(
const FHyperTwistTrainingReviewPlanState& ReviewPlan,
const FHyperTwistTrainingRunStepResult& StepResult,
const FHyperTwistTrainingAttempt& Attempt
)
{
FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan;
if (!UpdatedPlan.IsStructurallyValid() || !StepResult.IsStructurallyValid() || !Attempt.IsStructurallyValid())
{
return UpdatedPlan;
}
const int32 EntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[&Attempt](const FHyperTwistTrainingReviewPlanEntry& Entry)
{
return Entry.CaseId == Attempt.CaseId;
}
);
if (EntryIndex < 0)
{
return UpdatedPlan;
}
FHyperTwistTrainingReviewPlanEntry& Entry = UpdatedPlan.Entries[EntryIndex];
Entry.AttemptCount += 1;
Entry.LastAttemptId = Attempt.AttemptId;
Entry.LastOutcome = Attempt.Result;
Entry.LastCompletedAtUtc = Attempt.CompletedAtUtc;
if (Attempt.Result == EHyperTwistTrainingAttemptResult::Success)
{
Entry.SuccessCount += 1;
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Completed;
}
else if (Attempt.Result == EHyperTwistTrainingAttemptResult::Aborted)
{
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Skipped;
}
else
{
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat;
}
for (int32 Index = 0; Index < UpdatedPlan.Entries.Num(); ++Index)
{
if (Index == EntryIndex)
{
continue;
}
if (UpdatedPlan.Entries[Index].EntryState == EHyperTwistTrainingReviewPlanEntryState::Current)
{
UpdatedPlan.Entries[Index].EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending;
}
}
const EHyperTwistTrainingSessionState SessionState = StepResult.UpdatedRunState.Session.SessionState;
if (SessionState == EHyperTwistTrainingSessionState::Completed
|| SessionState == EHyperTwistTrainingSessionState::Aborted)
{
UpdatedPlan.bCompleted = true;
UpdatedPlan.CurrentEntryIndex = INDEX_NONE;
UpdatedPlan.CompletedAtUtc = !StepResult.UpdatedRunState.Session.EndedAtUtc.IsEmpty()
? StepResult.UpdatedRunState.Session.EndedAtUtc
: Attempt.CompletedAtUtc;
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}
FString NextCaseId;
if (StepResult.UpdatedRunState.CurrentSelection.IsStructurallyValid())
{
NextCaseId = StepResult.UpdatedRunState.CurrentSelection.TrainingCase.CaseId;
}
if (NextCaseId.IsEmpty())
{
NextCaseId = StepResult.UpdatedRunState.Session.CurrentCaseId;
}
int32 NextEntryIndex = INDEX_NONE;
if (!NextCaseId.IsEmpty())
{
NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[&NextCaseId](const FHyperTwistTrainingReviewPlanEntry& Candidate)
{
return Candidate.CaseId == NextCaseId
&& Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Completed
&& Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Skipped;
}
);
}
if (NextEntryIndex < 0)
{
NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[](const FHyperTwistTrainingReviewPlanEntry& Candidate)
{
return Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat
|| Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::Pending;
}
);
}
UpdatedPlan.CurrentEntryIndex = NextEntryIndex;
if (NextEntryIndex >= 0)
{
UpdatedPlan.Entries[NextEntryIndex].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current;
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}
FHyperTwistTrainingReviewFlowStatus UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(
const FHyperTwistTrainingReviewPlanState& ReviewPlan
)
{
FHyperTwistTrainingReviewFlowStatus FlowStatus;
if (!ReviewPlan.IsStructurallyValid())
{
return FlowStatus;
}
FlowStatus.PlanId = ReviewPlan.PlanId;
FlowStatus.bPlanComplete = ReviewPlan.bCompleted;
int32 CurrentEntryIndex = ReviewPlan.CurrentEntryIndex;
if (!ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex))
{
CurrentEntryIndex = ReviewPlan.Entries.IndexOfByPredicate(
[](const FHyperTwistTrainingReviewPlanEntry& Entry)
{
return Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current;
}
);
}
for (const FHyperTwistTrainingReviewPlanEntry& Entry : ReviewPlan.Entries)
{
switch (Entry.EntryState)
{
case EHyperTwistTrainingReviewPlanEntryState::Pending:
FlowStatus.PendingCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat:
FlowStatus.RepeatCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::Completed:
FlowStatus.CompletedCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::Skipped:
FlowStatus.SkippedCount += 1;
break;
default:
break;
}
if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat && Entry.AttemptCount >= 2)
{
FlowStatus.bNeedsCoachIntervention = true;
}
}
const bool bCoachFacingRepeatPressure =
FlowStatus.RepeatCount > 0
&& (
ReviewPlan.Policy.PolicyKind == EHyperTwistTrainingReviewPolicyKind::CoachReview
|| ReviewPlan.Policy.bPreferRecognitionReplayReview
);
if (bCoachFacingRepeatPressure)
{
FlowStatus.bNeedsCoachIntervention = true;
}
const int32 TotalTerminalCount = FlowStatus.CompletedCount + FlowStatus.SkippedCount;
if (ReviewPlan.Entries.Num() > 0)
{
FlowStatus.ProgressRatio = static_cast<float>(TotalTerminalCount) / static_cast<float>(ReviewPlan.Entries.Num());
}
if (ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex))
{
const FHyperTwistTrainingReviewPlanEntry& CurrentEntry = ReviewPlan.Entries[CurrentEntryIndex];
FlowStatus.CurrentCaseId = CurrentEntry.CaseId;
FlowStatus.CurrentPromptLabel = CurrentEntry.PromptLabel;
FlowStatus.bHasCurrentEntry = true;
}
if (FlowStatus.bPlanComplete || TotalTerminalCount >= ReviewPlan.Entries.Num())
{
FlowStatus.bPlanComplete = true;
FlowStatus.NextActionLabel = TEXT("review-complete");
}
else if (FlowStatus.bNeedsCoachIntervention)
{
FlowStatus.NextActionLabel = TEXT("coach-review-needed");
}
else if (FlowStatus.bHasCurrentEntry)
{
FlowStatus.NextActionLabel = TEXT("continue-review");
}
else if (FlowStatus.RepeatCount > 0)
{
FlowStatus.NextActionLabel = TEXT("repeat-failed-cases");
}
else if (FlowStatus.PendingCount > 0)
{
FlowStatus.NextActionLabel = TEXT("advance-to-next-case");
}
else
{
FlowStatus.NextActionLabel = TEXT("finalize-review");
}
return FlowStatus;
}
FHyperTwistTrainingReviewProgramSummary UHyperTwistTrainingRepositoryLibrary::DeriveReviewProgramSummary(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& UserId,
const FString& ReferenceUtc
)
{
FHyperTwistTrainingReviewProgramSummary Summary;
if (UserId.IsEmpty())
{
return Summary;
}
Summary.UserId = UserId;
Summary.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc);
const TArray<FHyperTwistTrainingMethodSegment> MethodSegments = DeriveLearnerMethodSegments(
RepositoryState,
UserId,
Summary.ReferenceUtc
);
const auto ResolveMethodSegmentIdForDeck = [&MethodSegments](const FString& DeckId) -> FString
{
for (const FHyperTwistTrainingMethodSegment& MethodSegment : MethodSegments)
{
if (MethodSegment.DeckIds.Contains(DeckId))
{
return MethodSegment.MethodDescriptor.SegmentId;
}
}
return FString();
};
int32 BestCarryoverScore = -1;
FString BestPlanCreatedAtUtc;
FString LatestCompletedAtUtc;
int32 DeliveryModeCounts[5] = {0, 0, 0, 0, 0};
TMap<EHyperTwistTrainingReviewPolicyKind, int32> PolicyCounts;
int64 CompletedPlanDurationAccumulator = 0;
int32 CompletedPlanDurationCount = 0;
float ProgressRatioAccumulator = 0.0f;
for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans)
{
if (!ReviewPlan.IsStructurallyValid() || ReviewPlan.UserId != UserId)
{
continue;
}
const FHyperTwistTrainingReviewFlowStatus FlowStatus = DeriveReviewFlowStatus(ReviewPlan);
const int32 CarryoverCount = FlowStatus.PendingCount
+ FlowStatus.RepeatCount
+ (FlowStatus.bHasCurrentEntry ? 1 : 0);
const int32 DeliveryModeIndex = FMath::Clamp(static_cast<int32>(ReviewPlan.DeliveryMode), 0, 4);
DeliveryModeCounts[DeliveryModeIndex] += 1;
PolicyCounts.FindOrAdd(ReviewPlan.Policy.PolicyKind) += 1;
Summary.TotalPlanCount += 1;
Summary.TotalEntryCount += ReviewPlan.Entries.Num();
Summary.CompletedEntryCount += FlowStatus.CompletedCount;
Summary.SkippedEntryCount += FlowStatus.SkippedCount;
ProgressRatioAccumulator += FlowStatus.ProgressRatio;
if (Summary.LastPlanCreatedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(
ReviewPlan.CreatedAtUtc,
Summary.LastPlanCreatedAtUtc
) > 0)
{
Summary.LastPlanCreatedAtUtc = ReviewPlan.CreatedAtUtc;
}
if (ReviewPlan.bCompleted || FlowStatus.bPlanComplete)
{
Summary.CompletedPlanCount += 1;
const FString CompletedAtUtc = !ReviewPlan.CompletedAtUtc.IsEmpty()
? ReviewPlan.CompletedAtUtc
: ReviewPlan.CreatedAtUtc;
if (LatestCompletedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(CompletedAtUtc, LatestCompletedAtUtc) > 0)
{
LatestCompletedAtUtc = CompletedAtUtc;
Summary.LastCompletedPlanId = ReviewPlan.PlanId;
}
const int32 PlanDurationMs = HyperTwistTrainingRepositoryLibraryInternal::ComputePlanDurationMs(
ReviewPlan,
Summary.ReferenceUtc
);
if (PlanDurationMs > 0)
{
CompletedPlanDurationAccumulator += PlanDurationMs;
CompletedPlanDurationCount += 1;
if (Summary.BestCompletedPlanDurationMs == 0 || PlanDurationMs < Summary.BestCompletedPlanDurationMs)
{
Summary.BestCompletedPlanDurationMs = PlanDurationMs;
}
}
continue;
}
Summary.PendingPlanIds.Add(ReviewPlan.PlanId);
Summary.PendingPlanCount += 1;
Summary.PendingEntryCount += FlowStatus.PendingCount;
Summary.RepeatEntryCount += FlowStatus.RepeatCount;
Summary.CarryoverCaseCount += CarryoverCount;
if (FlowStatus.bNeedsCoachIntervention)
{
Summary.CoachInterventionPlanCount += 1;
Summary.bNeedsCoachIntervention = true;
}
const int32 CarryoverScore = CarryoverCount * 100
+ (FlowStatus.bNeedsCoachIntervention ? 25 : 0)
+ (FlowStatus.bHasCurrentEntry ? 10 : 0);
if (CarryoverScore > BestCarryoverScore
|| (CarryoverScore == BestCarryoverScore
&& (BestPlanCreatedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(ReviewPlan.CreatedAtUtc, BestPlanCreatedAtUtc) > 0)))
{
BestCarryoverScore = CarryoverScore;
BestPlanCreatedAtUtc = ReviewPlan.CreatedAtUtc;
Summary.FocusDeckId = ReviewPlan.SourceDeckId;
Summary.FocusMethodSegmentId = ResolveMethodSegmentIdForDeck(ReviewPlan.SourceDeckId);
Summary.NextPlanId = ReviewPlan.PlanId;
if (FlowStatus.bHasCurrentEntry)
{
Summary.CurrentPlanId = ReviewPlan.PlanId;
}
}
if (Summary.CurrentPlanId.IsEmpty() && FlowStatus.bHasCurrentEntry)
{
Summary.CurrentPlanId = ReviewPlan.PlanId;
}
}
if (Summary.CurrentPlanId.IsEmpty())
{
Summary.CurrentPlanId = Summary.NextPlanId;
}
if (Summary.FocusMethodSegmentId.IsEmpty() && !Summary.FocusDeckId.IsEmpty())
{
Summary.FocusMethodSegmentId = ResolveMethodSegmentIdForDeck(Summary.FocusDeckId);
}
if (Summary.TotalPlanCount > 0)
{
Summary.AveragePlanProgressRatio = ProgressRatioAccumulator / static_cast<float>(Summary.TotalPlanCount);
}
if (Summary.PendingPlanCount > 0)
{
Summary.AveragePendingEntriesPerOpenPlan =
static_cast<float>(Summary.PendingEntryCount) / static_cast<float>(Summary.PendingPlanCount);
Summary.AverageCarryoverCasesPerOpenPlan =
static_cast<float>(Summary.CarryoverCaseCount) / static_cast<float>(Summary.PendingPlanCount);
}
if (CompletedPlanDurationCount > 0)
{
Summary.AverageCompletedPlanDurationMs = static_cast<int32>(
CompletedPlanDurationAccumulator / CompletedPlanDurationCount
);
}
int32 BestModeCount = -1;
for (int32 ModeIndex = 0; ModeIndex < UE_ARRAY_COUNT(DeliveryModeCounts); ++ModeIndex)
{
if (DeliveryModeCounts[ModeIndex] > BestModeCount)
{
BestModeCount = DeliveryModeCounts[ModeIndex];
Summary.DominantDeliveryMode = static_cast<EHyperTwistTrainingDeliveryMode>(ModeIndex);
}
}
int32 BestPolicyCount = -1;
for (const TPair<EHyperTwistTrainingReviewPolicyKind, int32>& Pair : PolicyCounts)
{
if (Pair.Value > BestPolicyCount)
{
BestPolicyCount = Pair.Value;
Summary.DominantPolicyKind = Pair.Key;
}
}
Summary.ProgramPressureScore =
static_cast<float>(Summary.CarryoverCaseCount * 1.0f)
+ static_cast<float>(Summary.RepeatEntryCount * 1.5f)
+ static_cast<float>(Summary.CoachInterventionPlanCount * 3.0f)
+ (Summary.CurrentPlanId.IsEmpty() ? 0.0f : 1.0f);
Summary.bHasCarryover = Summary.CarryoverCaseCount > 0 || Summary.PendingPlanCount > 0;
return Summary;
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::FinalizeReviewPlan(
const FHyperTwistTrainingReviewPlanState& ReviewPlan,
const FHyperTwistTrainingSession& Session
)
{
FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan;
if (!UpdatedPlan.IsStructurallyValid())
{
return UpdatedPlan;
}
UpdatedPlan.bCompleted = false;
UpdatedPlan.CurrentEntryIndex = INDEX_NONE;
UpdatedPlan.CompletedAtUtc = !Session.EndedAtUtc.IsEmpty()
? Session.EndedAtUtc
: FDateTime::UtcNow().ToIso8601();
for (FHyperTwistTrainingReviewPlanEntry& Entry : UpdatedPlan.Entries)
{
if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current)
{
Entry.EntryState = Session.SessionState == EHyperTwistTrainingSessionState::Aborted
? EHyperTwistTrainingReviewPlanEntryState::Skipped
: EHyperTwistTrainingReviewPlanEntryState::Pending;
}
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}

View file

@ -0,0 +1,26 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#pragma once
#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h"
namespace HyperTwistTrainingRepositoryLibraryInternal
{
int32 CompareUtcStrings(const FString& Left, const FString& Right);
FString ResolveReferenceUtc(const FString& ReferenceUtc);
int32 ComputePlanDurationMs(
const FHyperTwistTrainingReviewPlanState& ReviewPlan,
const FString& ReferenceUtc
);
FHyperTwistTrainingReviewPlanState NormalizeReviewPlanState(
const FHyperTwistTrainingReviewPlanState& ReviewPlan
);
int32 ResolvePolicyCaseLimit(
const FHyperTwistTrainingReviewPolicy& ReviewPolicy,
int32 RequestedMaxCases
);
}

View file

@ -0,0 +1,809 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h"
#include "HyperTwistTrainingRepositoryLibraryInternal.h"
FHyperTwistTrainingReviewPolicy UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy(
EHyperTwistTrainingDeliveryMode DeliveryMode
)
{
FHyperTwistTrainingReviewPolicy ReviewPolicy;
ReviewPolicy.DeliveryMode = DeliveryMode;
ReviewPolicy.MaxCases = 5;
ReviewPolicy.NewCaseBias = 0.0f;
ReviewPolicy.DueCaseBias = 0.0f;
ReviewPolicy.RecognitionBias = 0.0f;
ReviewPolicy.AccuracyBias = 0.0f;
ReviewPolicy.bRepeatFailuresImmediately = true;
ReviewPolicy.bPreferShortReviewSets = false;
switch (DeliveryMode)
{
case EHyperTwistTrainingDeliveryMode::VirtualCube:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::VirtualCubePractice;
ReviewPolicy.PolicyLabel = TEXT("virtual-cube-practice");
ReviewPolicy.MaxCases = 6;
ReviewPolicy.NewCaseBias = 80.0f;
ReviewPolicy.DueCaseBias = 95.0f;
ReviewPolicy.AccuracyBias = 20.0f;
break;
case EHyperTwistTrainingDeliveryMode::SmartDevice:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::SmartDeviceAccuracy;
ReviewPolicy.PolicyLabel = TEXT("smart-device-accuracy");
ReviewPolicy.MaxCases = 5;
ReviewPolicy.DueCaseBias = 115.0f;
ReviewPolicy.AccuracyBias = 90.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::RecognitionAssisted:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::RecognitionAssistedScan;
ReviewPolicy.PolicyLabel = TEXT("recognition-assisted-scan");
ReviewPolicy.MaxCases = 4;
ReviewPolicy.NewCaseBias = -20.0f;
ReviewPolicy.DueCaseBias = 105.0f;
ReviewPolicy.RecognitionBias = 130.0f;
ReviewPolicy.AccuracyBias = 45.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::CoachReviewed:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::CoachReview;
ReviewPolicy.PolicyLabel = TEXT("coach-review");
ReviewPolicy.MaxCases = 4;
ReviewPolicy.NewCaseBias = 35.0f;
ReviewPolicy.DueCaseBias = 90.0f;
ReviewPolicy.RecognitionBias = 30.0f;
ReviewPolicy.AccuracyBias = 110.0f;
ReviewPolicy.bPreferShortReviewSets = true;
break;
case EHyperTwistTrainingDeliveryMode::Timer:
default:
ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::TimerBalanced;
ReviewPolicy.PolicyLabel = TEXT("timer-balanced");
ReviewPolicy.MaxCases = 5;
ReviewPolicy.NewCaseBias = -10.0f;
ReviewPolicy.DueCaseBias = 120.0f;
ReviewPolicy.AccuracyBias = 35.0f;
break;
}
return ReviewPolicy;
}
TArray<FHyperTwistTrainingCaseRecommendation> UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingDeck& Deck,
const FString& UserId,
const FString& ReferenceUtc,
int32 MaxRecommendations
)
{
TArray<FHyperTwistTrainingCaseRecommendation> Recommendations;
if (!Deck.IsStructurallyValid())
{
return Recommendations;
}
for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
{
if (!TrainingCase.IsStructurallyValid())
{
continue;
}
FHyperTwistTrainingCaseLearnerState LearnerState;
TryGetLearnerState(RepositoryState, UserId, Deck.DeckId, TrainingCase.CaseId, LearnerState);
FHyperTwistTrainingCaseRecommendation Recommendation = MakeCaseRecommendation(
TrainingCase,
LearnerState,
ReferenceUtc
);
if (Recommendation.IsStructurallyValid())
{
Recommendations.Add(Recommendation);
}
}
Recommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right)
{
if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore))
{
return Left.RecommendationScore > Right.RecommendationScore;
}
if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue))
{
return Left.DaysUntilDue < Right.DaysUntilDue;
}
return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId;
});
if (MaxRecommendations > 0 && Recommendations.Num() > MaxRecommendations)
{
Recommendations.SetNum(MaxRecommendations, EAllowShrinking::No);
}
return Recommendations;
}
TArray<FHyperTwistTrainingCaseRecommendation> UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations(
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
const FHyperTwistTrainingReviewPolicy& ReviewPolicy,
int32 MaxRecommendations
)
{
TArray<FHyperTwistTrainingCaseRecommendation> FilteredRecommendations;
FilteredRecommendations.Reserve(Recommendations.Num());
for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations)
{
if (!Recommendation.IsStructurallyValid())
{
continue;
}
FHyperTwistTrainingCaseRecommendation FilteredRecommendation = Recommendation;
float AdjustedScore = FilteredRecommendation.RecommendationScore;
if (FilteredRecommendation.bIsNewCase)
{
AdjustedScore += ReviewPolicy.NewCaseBias;
}
if (FilteredRecommendation.bIsDue)
{
AdjustedScore += ReviewPolicy.DueCaseBias;
}
if (FilteredRecommendation.TrainingCase.PromptKind == EHyperTwistTrainingPromptKind::Recognition
|| ReviewPolicy.DeliveryMode == EHyperTwistTrainingDeliveryMode::RecognitionAssisted)
{
AdjustedScore += ReviewPolicy.RecognitionBias;
}
if (FilteredRecommendation.bRequiresReviewFocus
|| FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Again
|| FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Hard
|| FilteredRecommendation.LearnerState.LapseCount > 0)
{
AdjustedScore += ReviewPolicy.AccuracyBias;
}
if (ReviewPolicy.bPreferShortReviewSets
&& !FilteredRecommendation.bIsDue
&& !FilteredRecommendation.bRequiresReviewFocus
&& !FilteredRecommendation.bIsNewCase)
{
AdjustedScore -= 55.0f;
}
FilteredRecommendation.RecommendationScore = AdjustedScore;
FilteredRecommendations.Add(FilteredRecommendation);
}
FilteredRecommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right)
{
if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore))
{
return Left.RecommendationScore > Right.RecommendationScore;
}
if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue))
{
return Left.DaysUntilDue < Right.DaysUntilDue;
}
return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId;
});
const int32 EffectiveMaxRecommendations = HyperTwistTrainingRepositoryLibraryInternal::ResolvePolicyCaseLimit(
ReviewPolicy,
MaxRecommendations
);
if (EffectiveMaxRecommendations > 0 && FilteredRecommendations.Num() > EffectiveMaxRecommendations)
{
FilteredRecommendations.SetNum(EffectiveMaxRecommendations, EAllowShrinking::No);
}
return FilteredRecommendations;
}
FHyperTwistTrainingDeck UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations(
const FHyperTwistTrainingDeck& SourceDeck,
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
int32 MaxCases
)
{
FHyperTwistTrainingDeck ReviewDeck = SourceDeck;
if (!SourceDeck.IsStructurallyValid())
{
return FHyperTwistTrainingDeck();
}
ReviewDeck.DeckId = SourceDeck.DeckId + TEXT("/review");
ReviewDeck.Title = SourceDeck.Title + TEXT(" Review");
ReviewDeck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Spaced;
ReviewDeck.Tags = SourceDeck.Tags;
ReviewDeck.Tags.AddUnique(TEXT("review"));
ReviewDeck.Tags.AddUnique(TEXT("recommendation-shaped"));
ReviewDeck.Cases.Reset();
const int32 EffectiveMaxCases = MaxCases > 0 ? MaxCases : Recommendations.Num();
for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations)
{
if (!Recommendation.IsStructurallyValid())
{
continue;
}
ReviewDeck.Cases.Add(Recommendation.TrainingCase);
if (ReviewDeck.Cases.Num() >= EffectiveMaxCases)
{
break;
}
}
if (ReviewDeck.Cases.Num() == 0)
{
return FHyperTwistTrainingDeck();
}
return ReviewDeck;
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::BuildReviewPlan(
const FHyperTwistTrainingDeck& SourceDeck,
const FHyperTwistTrainingDeck& ReviewDeck,
const FString& UserId,
const TArray<FHyperTwistTrainingCaseRecommendation>& Recommendations,
const FHyperTwistTrainingReviewPolicy& ReviewPolicy,
const FString& PlanId,
const FString& ReferenceUtc,
EHyperTwistTrainingDeliveryMode DeliveryMode
)
{
FHyperTwistTrainingReviewPlanState ReviewPlan;
if (!SourceDeck.IsStructurallyValid() || !ReviewDeck.IsStructurallyValid() || UserId.IsEmpty())
{
return ReviewPlan;
}
ReviewPlan.PlanId = PlanId.IsEmpty() ? FString::Printf(TEXT("review_plan_%s"), *ReviewDeck.DeckId) : PlanId;
ReviewPlan.UserId = UserId;
ReviewPlan.SourceDeckId = SourceDeck.DeckId;
ReviewPlan.ReviewDeckId = ReviewDeck.DeckId;
ReviewPlan.ReferenceUtc = ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc;
ReviewPlan.DeliveryMode = DeliveryMode;
ReviewPlan.Policy = ReviewPolicy;
ReviewPlan.CreatedAtUtc = ReviewPlan.ReferenceUtc;
ReviewPlan.StartedAtUtc = ReviewPlan.ReferenceUtc;
for (const FHyperTwistTrainingCase& TrainingCase : ReviewDeck.Cases)
{
if (!TrainingCase.IsStructurallyValid())
{
continue;
}
const FHyperTwistTrainingCaseRecommendation* MatchingRecommendation = Recommendations.FindByPredicate(
[&TrainingCase](const FHyperTwistTrainingCaseRecommendation& Candidate)
{
return Candidate.TrainingCase.CaseId == TrainingCase.CaseId;
}
);
FHyperTwistTrainingReviewPlanEntry Entry;
Entry.CaseId = TrainingCase.CaseId;
Entry.PromptLabel = TrainingCase.PromptLabel;
Entry.RecommendationReason = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationReason : TEXT("review-case");
Entry.RecommendationScore = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationScore : 0.0f;
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending;
ReviewPlan.Entries.Add(Entry);
}
if (ReviewPlan.Entries.Num() > 0)
{
ReviewPlan.CurrentEntryIndex = 0;
ReviewPlan.Entries[0].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current;
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(ReviewPlan);
}
FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingReviewPlanState& ReviewPlan
)
{
FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState;
const FHyperTwistTrainingReviewPlanState NormalizedReviewPlan =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(ReviewPlan);
if (!NormalizedReviewPlan.IsStructurallyValid())
{
return UpdatedState;
}
const int32 ExistingIndex = UpdatedState.ReviewPlans.IndexOfByPredicate(
[&NormalizedReviewPlan](const FHyperTwistTrainingReviewPlanState& ExistingPlan)
{
return ExistingPlan.PlanId == NormalizedReviewPlan.PlanId;
}
);
if (ExistingIndex >= 0)
{
UpdatedState.ReviewPlans[ExistingIndex] = NormalizedReviewPlan;
}
else
{
UpdatedState.ReviewPlans.Add(NormalizedReviewPlan);
}
return UpdatedState;
}
bool UHyperTwistTrainingRepositoryLibrary::TryGetReviewPlan(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& PlanId,
FHyperTwistTrainingReviewPlanState& OutReviewPlan
)
{
OutReviewPlan = FHyperTwistTrainingReviewPlanState();
if (PlanId.IsEmpty())
{
return false;
}
const FHyperTwistTrainingReviewPlanState* ExistingPlan = RepositoryState.ReviewPlans.FindByPredicate(
[&PlanId](const FHyperTwistTrainingReviewPlanState& Candidate)
{
return Candidate.PlanId == PlanId;
}
);
if (ExistingPlan == nullptr)
{
return false;
}
OutReviewPlan = HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(*ExistingPlan);
return OutReviewPlan.IsStructurallyValid();
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::UpdateReviewPlanAfterAttempt(
const FHyperTwistTrainingReviewPlanState& ReviewPlan,
const FHyperTwistTrainingRunStepResult& StepResult,
const FHyperTwistTrainingAttempt& Attempt
)
{
FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan;
if (!UpdatedPlan.IsStructurallyValid() || !StepResult.IsStructurallyValid() || !Attempt.IsStructurallyValid())
{
return UpdatedPlan;
}
const int32 EntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[&Attempt](const FHyperTwistTrainingReviewPlanEntry& Entry)
{
return Entry.CaseId == Attempt.CaseId;
}
);
if (EntryIndex < 0)
{
return UpdatedPlan;
}
FHyperTwistTrainingReviewPlanEntry& Entry = UpdatedPlan.Entries[EntryIndex];
Entry.AttemptCount += 1;
Entry.LastAttemptId = Attempt.AttemptId;
Entry.LastOutcome = Attempt.Result;
Entry.LastCompletedAtUtc = Attempt.CompletedAtUtc;
if (Attempt.Result == EHyperTwistTrainingAttemptResult::Success)
{
Entry.SuccessCount += 1;
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Completed;
}
else if (Attempt.Result == EHyperTwistTrainingAttemptResult::Aborted)
{
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Skipped;
}
else
{
Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat;
}
for (int32 Index = 0; Index < UpdatedPlan.Entries.Num(); ++Index)
{
if (Index == EntryIndex)
{
continue;
}
if (UpdatedPlan.Entries[Index].EntryState == EHyperTwistTrainingReviewPlanEntryState::Current)
{
UpdatedPlan.Entries[Index].EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending;
}
}
const EHyperTwistTrainingSessionState SessionState = StepResult.UpdatedRunState.Session.SessionState;
if (SessionState == EHyperTwistTrainingSessionState::Completed
|| SessionState == EHyperTwistTrainingSessionState::Aborted)
{
UpdatedPlan.bCompleted = true;
UpdatedPlan.CurrentEntryIndex = INDEX_NONE;
UpdatedPlan.CompletedAtUtc = !StepResult.UpdatedRunState.Session.EndedAtUtc.IsEmpty()
? StepResult.UpdatedRunState.Session.EndedAtUtc
: Attempt.CompletedAtUtc;
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}
FString NextCaseId;
if (StepResult.UpdatedRunState.CurrentSelection.IsStructurallyValid())
{
NextCaseId = StepResult.UpdatedRunState.CurrentSelection.TrainingCase.CaseId;
}
if (NextCaseId.IsEmpty())
{
NextCaseId = StepResult.UpdatedRunState.Session.CurrentCaseId;
}
int32 NextEntryIndex = INDEX_NONE;
if (!NextCaseId.IsEmpty())
{
NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[&NextCaseId](const FHyperTwistTrainingReviewPlanEntry& Candidate)
{
return Candidate.CaseId == NextCaseId
&& Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Completed
&& Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Skipped;
}
);
}
if (NextEntryIndex < 0)
{
NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate(
[](const FHyperTwistTrainingReviewPlanEntry& Candidate)
{
return Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat
|| Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::Pending;
}
);
}
UpdatedPlan.CurrentEntryIndex = NextEntryIndex;
if (NextEntryIndex >= 0)
{
UpdatedPlan.Entries[NextEntryIndex].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current;
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}
FHyperTwistTrainingReviewFlowStatus UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(
const FHyperTwistTrainingReviewPlanState& ReviewPlan
)
{
FHyperTwistTrainingReviewFlowStatus FlowStatus;
if (!ReviewPlan.IsStructurallyValid())
{
return FlowStatus;
}
FlowStatus.PlanId = ReviewPlan.PlanId;
FlowStatus.bPlanComplete = ReviewPlan.bCompleted;
int32 CurrentEntryIndex = ReviewPlan.CurrentEntryIndex;
if (!ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex))
{
CurrentEntryIndex = ReviewPlan.Entries.IndexOfByPredicate(
[](const FHyperTwistTrainingReviewPlanEntry& Entry)
{
return Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current;
}
);
}
for (const FHyperTwistTrainingReviewPlanEntry& Entry : ReviewPlan.Entries)
{
switch (Entry.EntryState)
{
case EHyperTwistTrainingReviewPlanEntryState::Pending:
FlowStatus.PendingCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat:
FlowStatus.RepeatCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::Completed:
FlowStatus.CompletedCount += 1;
break;
case EHyperTwistTrainingReviewPlanEntryState::Skipped:
FlowStatus.SkippedCount += 1;
break;
default:
break;
}
if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat && Entry.AttemptCount >= 2)
{
FlowStatus.bNeedsCoachIntervention = true;
}
}
const bool bCoachFacingRepeatPressure =
FlowStatus.RepeatCount > 0
&& (
ReviewPlan.Policy.PolicyKind == EHyperTwistTrainingReviewPolicyKind::CoachReview
|| ReviewPlan.Policy.bPreferRecognitionReplayReview
);
if (bCoachFacingRepeatPressure)
{
FlowStatus.bNeedsCoachIntervention = true;
}
const int32 TotalTerminalCount = FlowStatus.CompletedCount + FlowStatus.SkippedCount;
if (ReviewPlan.Entries.Num() > 0)
{
FlowStatus.ProgressRatio = static_cast<float>(TotalTerminalCount) / static_cast<float>(ReviewPlan.Entries.Num());
}
if (ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex))
{
const FHyperTwistTrainingReviewPlanEntry& CurrentEntry = ReviewPlan.Entries[CurrentEntryIndex];
FlowStatus.CurrentCaseId = CurrentEntry.CaseId;
FlowStatus.CurrentPromptLabel = CurrentEntry.PromptLabel;
FlowStatus.bHasCurrentEntry = true;
}
if (FlowStatus.bPlanComplete || TotalTerminalCount >= ReviewPlan.Entries.Num())
{
FlowStatus.bPlanComplete = true;
FlowStatus.NextActionLabel = TEXT("review-complete");
}
else if (FlowStatus.bNeedsCoachIntervention)
{
FlowStatus.NextActionLabel = TEXT("coach-review-needed");
}
else if (FlowStatus.bHasCurrentEntry)
{
FlowStatus.NextActionLabel = TEXT("continue-review");
}
else if (FlowStatus.RepeatCount > 0)
{
FlowStatus.NextActionLabel = TEXT("repeat-failed-cases");
}
else if (FlowStatus.PendingCount > 0)
{
FlowStatus.NextActionLabel = TEXT("advance-to-next-case");
}
else
{
FlowStatus.NextActionLabel = TEXT("finalize-review");
}
return FlowStatus;
}
FHyperTwistTrainingReviewProgramSummary UHyperTwistTrainingRepositoryLibrary::DeriveReviewProgramSummary(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& UserId,
const FString& ReferenceUtc
)
{
FHyperTwistTrainingReviewProgramSummary Summary;
if (UserId.IsEmpty())
{
return Summary;
}
Summary.UserId = UserId;
Summary.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc);
const TArray<FHyperTwistTrainingMethodSegment> MethodSegments = DeriveLearnerMethodSegments(
RepositoryState,
UserId,
Summary.ReferenceUtc
);
const auto ResolveMethodSegmentIdForDeck = [&MethodSegments](const FString& DeckId) -> FString
{
for (const FHyperTwistTrainingMethodSegment& MethodSegment : MethodSegments)
{
if (MethodSegment.DeckIds.Contains(DeckId))
{
return MethodSegment.MethodDescriptor.SegmentId;
}
}
return FString();
};
int32 BestCarryoverScore = -1;
FString BestPlanCreatedAtUtc;
FString LatestCompletedAtUtc;
int32 DeliveryModeCounts[5] = {0, 0, 0, 0, 0};
TMap<EHyperTwistTrainingReviewPolicyKind, int32> PolicyCounts;
int64 CompletedPlanDurationAccumulator = 0;
int32 CompletedPlanDurationCount = 0;
float ProgressRatioAccumulator = 0.0f;
for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans)
{
if (!ReviewPlan.IsStructurallyValid() || ReviewPlan.UserId != UserId)
{
continue;
}
const FHyperTwistTrainingReviewFlowStatus FlowStatus = DeriveReviewFlowStatus(ReviewPlan);
const int32 CarryoverCount = FlowStatus.PendingCount
+ FlowStatus.RepeatCount
+ (FlowStatus.bHasCurrentEntry ? 1 : 0);
const int32 DeliveryModeIndex = FMath::Clamp(static_cast<int32>(ReviewPlan.DeliveryMode), 0, 4);
DeliveryModeCounts[DeliveryModeIndex] += 1;
PolicyCounts.FindOrAdd(ReviewPlan.Policy.PolicyKind) += 1;
Summary.TotalPlanCount += 1;
Summary.TotalEntryCount += ReviewPlan.Entries.Num();
Summary.CompletedEntryCount += FlowStatus.CompletedCount;
Summary.SkippedEntryCount += FlowStatus.SkippedCount;
ProgressRatioAccumulator += FlowStatus.ProgressRatio;
if (Summary.LastPlanCreatedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(
ReviewPlan.CreatedAtUtc,
Summary.LastPlanCreatedAtUtc
) > 0)
{
Summary.LastPlanCreatedAtUtc = ReviewPlan.CreatedAtUtc;
}
if (ReviewPlan.bCompleted || FlowStatus.bPlanComplete)
{
Summary.CompletedPlanCount += 1;
const FString CompletedAtUtc = !ReviewPlan.CompletedAtUtc.IsEmpty()
? ReviewPlan.CompletedAtUtc
: ReviewPlan.CreatedAtUtc;
if (LatestCompletedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(CompletedAtUtc, LatestCompletedAtUtc) > 0)
{
LatestCompletedAtUtc = CompletedAtUtc;
Summary.LastCompletedPlanId = ReviewPlan.PlanId;
}
const int32 PlanDurationMs = HyperTwistTrainingRepositoryLibraryInternal::ComputePlanDurationMs(
ReviewPlan,
Summary.ReferenceUtc
);
if (PlanDurationMs > 0)
{
CompletedPlanDurationAccumulator += PlanDurationMs;
CompletedPlanDurationCount += 1;
if (Summary.BestCompletedPlanDurationMs == 0 || PlanDurationMs < Summary.BestCompletedPlanDurationMs)
{
Summary.BestCompletedPlanDurationMs = PlanDurationMs;
}
}
continue;
}
Summary.PendingPlanIds.Add(ReviewPlan.PlanId);
Summary.PendingPlanCount += 1;
Summary.PendingEntryCount += FlowStatus.PendingCount;
Summary.RepeatEntryCount += FlowStatus.RepeatCount;
Summary.CarryoverCaseCount += CarryoverCount;
if (FlowStatus.bNeedsCoachIntervention)
{
Summary.CoachInterventionPlanCount += 1;
Summary.bNeedsCoachIntervention = true;
}
const int32 CarryoverScore = CarryoverCount * 100
+ (FlowStatus.bNeedsCoachIntervention ? 25 : 0)
+ (FlowStatus.bHasCurrentEntry ? 10 : 0);
if (CarryoverScore > BestCarryoverScore
|| (CarryoverScore == BestCarryoverScore
&& (BestPlanCreatedAtUtc.IsEmpty()
|| HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(ReviewPlan.CreatedAtUtc, BestPlanCreatedAtUtc) > 0)))
{
BestCarryoverScore = CarryoverScore;
BestPlanCreatedAtUtc = ReviewPlan.CreatedAtUtc;
Summary.FocusDeckId = ReviewPlan.SourceDeckId;
Summary.FocusMethodSegmentId = ResolveMethodSegmentIdForDeck(ReviewPlan.SourceDeckId);
Summary.NextPlanId = ReviewPlan.PlanId;
if (FlowStatus.bHasCurrentEntry)
{
Summary.CurrentPlanId = ReviewPlan.PlanId;
}
}
if (Summary.CurrentPlanId.IsEmpty() && FlowStatus.bHasCurrentEntry)
{
Summary.CurrentPlanId = ReviewPlan.PlanId;
}
}
if (Summary.CurrentPlanId.IsEmpty())
{
Summary.CurrentPlanId = Summary.NextPlanId;
}
if (Summary.FocusMethodSegmentId.IsEmpty() && !Summary.FocusDeckId.IsEmpty())
{
Summary.FocusMethodSegmentId = ResolveMethodSegmentIdForDeck(Summary.FocusDeckId);
}
if (Summary.TotalPlanCount > 0)
{
Summary.AveragePlanProgressRatio = ProgressRatioAccumulator / static_cast<float>(Summary.TotalPlanCount);
}
if (Summary.PendingPlanCount > 0)
{
Summary.AveragePendingEntriesPerOpenPlan =
static_cast<float>(Summary.PendingEntryCount) / static_cast<float>(Summary.PendingPlanCount);
Summary.AverageCarryoverCasesPerOpenPlan =
static_cast<float>(Summary.CarryoverCaseCount) / static_cast<float>(Summary.PendingPlanCount);
}
if (CompletedPlanDurationCount > 0)
{
Summary.AverageCompletedPlanDurationMs = static_cast<int32>(
CompletedPlanDurationAccumulator / CompletedPlanDurationCount
);
}
int32 BestModeCount = -1;
for (int32 ModeIndex = 0; ModeIndex < UE_ARRAY_COUNT(DeliveryModeCounts); ++ModeIndex)
{
if (DeliveryModeCounts[ModeIndex] > BestModeCount)
{
BestModeCount = DeliveryModeCounts[ModeIndex];
Summary.DominantDeliveryMode = static_cast<EHyperTwistTrainingDeliveryMode>(ModeIndex);
}
}
int32 BestPolicyCount = -1;
for (const TPair<EHyperTwistTrainingReviewPolicyKind, int32>& Pair : PolicyCounts)
{
if (Pair.Value > BestPolicyCount)
{
BestPolicyCount = Pair.Value;
Summary.DominantPolicyKind = Pair.Key;
}
}
Summary.ProgramPressureScore =
static_cast<float>(Summary.CarryoverCaseCount * 1.0f)
+ static_cast<float>(Summary.RepeatEntryCount * 1.5f)
+ static_cast<float>(Summary.CoachInterventionPlanCount * 3.0f)
+ (Summary.CurrentPlanId.IsEmpty() ? 0.0f : 1.0f);
Summary.bHasCarryover = Summary.CarryoverCaseCount > 0 || Summary.PendingPlanCount > 0;
return Summary;
}
FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::FinalizeReviewPlan(
const FHyperTwistTrainingReviewPlanState& ReviewPlan,
const FHyperTwistTrainingSession& Session
)
{
FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan;
if (!UpdatedPlan.IsStructurallyValid())
{
return UpdatedPlan;
}
UpdatedPlan.bCompleted = false;
UpdatedPlan.CurrentEntryIndex = INDEX_NONE;
UpdatedPlan.CompletedAtUtc = !Session.EndedAtUtc.IsEmpty()
? Session.EndedAtUtc
: FDateTime::UtcNow().ToIso8601();
for (FHyperTwistTrainingReviewPlanEntry& Entry : UpdatedPlan.Entries)
{
if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current)
{
Entry.EntryState = Session.SessionState == EHyperTwistTrainingSessionState::Aborted
? EHyperTwistTrainingReviewPlanEntryState::Skipped
: EHyperTwistTrainingReviewPlanEntryState::Pending;
}
}
return HyperTwistTrainingRepositoryLibraryInternal::NormalizeReviewPlanState(UpdatedPlan);
}

View file

@ -1588,3 +1588,48 @@ Latest exact-source public/manual parity follow-up later on `2026-06-29`:
hotspot exists, so the next native refactor seam remains a real task
- the right next refactor target remains the same-family training-repository
extraction work rather than reopening browser or product topology
Latest native repository review-family split follow-up later on `2026-06-29`:
- the next same-family native continuation reduced the largest remaining
training-repository hotspot without widening product scope:
- moved the review-policy, recommendation shaping, review-deck, review-plan,
and review-program public method family out of
`HyperTwistTrainingRepositoryLibrary.cpp`
- added a private internal declaration header at
`UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryInternal.h`
so the split translation unit can share the bounded internal helpers it
actually needs
- added the dedicated implementation unit at
`UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryReview.cpp`
- reduced the main repository file to `31,976` lines while the extracted
review-family unit now holds `809` lines
- the HyperTwist-owned structural loop stayed flat on that exact native split:
- `scripts/run-hypertwist-sentrux-source-only.sh`
- `Quality: 6234`
- all `7` rules pass
- `scripts/run-hypertwist-sentrux-gate.sh`
- comparison result:
- `Quality: 6234 -> 6234`
- `Coupling: 0.15 -> 0.15`
- `Cycles: 0 -> 0`
- `God files: 1 -> 1`
- `No degradation detected`
- the canonical Windows Unreal proof for that native packet was then recovered
through the maintained reverse-SSH lane:
- the earlier long full rebuild compiled the touched split files plus linked
the main `UnrealHyperTwist` and `UnrealMCPChong` editor modules cleanly,
but its final session handle was lost before a trustworthy close banner was
captured
- the packet was therefore revalidated honestly instead of inferred:
- reran `scripts/run-hypertwist-remote-unreal-build.sh --worktree-root 'C:\htpp'`
- `Result: Succeeded`
- UnrealBuildTool `Total execution time: 4.08 seconds`
- current truthful reading after this native follow-up:
- the extracted review-family seam is now build-proven on the canonical
Windows lane and safe to land as a real native maintainability packet
- the remaining native refactor work is no longer whether this split is
valid; it is the next same-family hotspot extraction inside the training
repository library
- the best next bounded seam remains the coach action-plan and
coach-session-queue cluster rather than any fresh product-family widening

View file

@ -2414,3 +2414,45 @@ Latest same-family responsive public-route expansion follow-up on `2026-06-29`:
remains a preview deployment rather than full public launch because the
checkout, Windows download, billing-secret, and non-loopback auth-core
launch conditions are not yet satisfied
## Latest native repository review-family split follow-up later on `2026-06-29`
- the next same-family native continuation then reduced the largest remaining
training-repository hotspot without widening simulator or website scope:
- the review-policy, recommendation shaping, review-deck, review-plan, and
review-program public method family was moved out of
`HyperTwistTrainingRepositoryLibrary.cpp`
- a private helper declaration seam now lives at
`UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryInternal.h`
- the extracted implementation family now lives at
`UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryReview.cpp`
- the main repository file now stands at `31,976` lines and the extracted
review-family unit stands at `809` lines
- the HyperTwist-owned structural loop stayed healthy on that exact native
state:
- `scripts/run-hypertwist-sentrux-source-only.sh`
- `Quality: 6234`
- all `7` rules pass
- `scripts/run-hypertwist-sentrux-gate.sh`
- comparison result:
- `Quality: 6234 -> 6234`
- `Coupling: 0.15 -> 0.15`
- `Cycles: 0 -> 0`
- `God files: 1 -> 1`
- `No degradation detected`
- the canonical Windows Unreal proof for that native split was then recovered
honestly through the maintained reverse-SSH lane:
- the first long full rebuild had already compiled the touched split files
and linked the main editor modules cleanly, but the final session handle
was lost before a trustworthy terminal banner could be captured
- the packet was therefore rerun rather than inferred:
- `scripts/run-hypertwist-remote-unreal-build.sh --worktree-root 'C:\htpp'`
- `Result: Succeeded`
- UnrealBuildTool `Total execution time: 4.08 seconds`
- current truthful reading after this native follow-up:
- the review-family extraction is now build-proven on the canonical Windows
lane
- vanilla refactoring still is not fully complete because the training
repository library still contains the remaining same-family hotspot
- the best next bounded native seam remains the coach action-plan and
coach-session-queue cluster instead of any fresh product-family widening