diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index df3dc39..b5679c3 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -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 UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck( - const FHyperTwistTrainingRepositoryState& RepositoryState, - const FHyperTwistTrainingDeck& Deck, - const FString& UserId, - const FString& ReferenceUtc, - int32 MaxRecommendations -) -{ - TArray 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 UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations( - const TArray& Recommendations, - const FHyperTwistTrainingReviewPolicy& ReviewPolicy, - int32 MaxRecommendations -) -{ - TArray 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& 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& 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(TotalTerminalCount) / static_cast(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 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 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(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(Summary.TotalPlanCount); - } - if (Summary.PendingPlanCount > 0) - { - Summary.AveragePendingEntriesPerOpenPlan = - static_cast(Summary.PendingEntryCount) / static_cast(Summary.PendingPlanCount); - Summary.AverageCarryoverCasesPerOpenPlan = - static_cast(Summary.CarryoverCaseCount) / static_cast(Summary.PendingPlanCount); - } - if (CompletedPlanDurationCount > 0) - { - Summary.AverageCompletedPlanDurationMs = static_cast( - 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(ModeIndex); - } - } - - int32 BestPolicyCount = -1; - for (const TPair& Pair : PolicyCounts) - { - if (Pair.Value > BestPolicyCount) - { - BestPolicyCount = Pair.Value; - Summary.DominantPolicyKind = Pair.Key; - } - } - - Summary.ProgramPressureScore = - static_cast(Summary.CarryoverCaseCount * 1.0f) - + static_cast(Summary.RepeatEntryCount * 1.5f) - + static_cast(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); -} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryInternal.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryInternal.h new file mode 100644 index 0000000..25fbcbb --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryInternal.h @@ -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 + ); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryReview.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryReview.cpp new file mode 100644 index 0000000..cd3250c --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibraryReview.cpp @@ -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 UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingDeck& Deck, + const FString& UserId, + const FString& ReferenceUtc, + int32 MaxRecommendations +) +{ + TArray 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 UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations( + const TArray& Recommendations, + const FHyperTwistTrainingReviewPolicy& ReviewPolicy, + int32 MaxRecommendations +) +{ + TArray 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& 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& 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(TotalTerminalCount) / static_cast(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 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 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(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(Summary.TotalPlanCount); + } + if (Summary.PendingPlanCount > 0) + { + Summary.AveragePendingEntriesPerOpenPlan = + static_cast(Summary.PendingEntryCount) / static_cast(Summary.PendingPlanCount); + Summary.AverageCarryoverCasesPerOpenPlan = + static_cast(Summary.CarryoverCaseCount) / static_cast(Summary.PendingPlanCount); + } + if (CompletedPlanDurationCount > 0) + { + Summary.AverageCompletedPlanDurationMs = static_cast( + 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(ModeIndex); + } + } + + int32 BestPolicyCount = -1; + for (const TPair& Pair : PolicyCounts) + { + if (Pair.Value > BestPolicyCount) + { + BestPolicyCount = Pair.Value; + Summary.DominantPolicyKind = Pair.Key; + } + } + + Summary.ProgramPressureScore = + static_cast(Summary.CarryoverCaseCount * 1.0f) + + static_cast(Summary.RepeatEntryCount * 1.5f) + + static_cast(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); +} diff --git a/docs/ops/HYPERTWIST_REFACTORING_TOOLCHAIN_2026-06-22.md b/docs/ops/HYPERTWIST_REFACTORING_TOOLCHAIN_2026-06-22.md index fa55ba6..75b297b 100644 --- a/docs/ops/HYPERTWIST_REFACTORING_TOOLCHAIN_2026-06-22.md +++ b/docs/ops/HYPERTWIST_REFACTORING_TOOLCHAIN_2026-06-22.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md index d8c2de8..7c2a8eb 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md @@ -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