From d0582c4ce7d4b1e1f639e619e15f6894296504f1 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sun, 17 May 2026 22:41:18 -0500 Subject: [PATCH] =?UTF-8?q?Full=20Table=20=E2=80=94=20vary=20tense=20and?= =?UTF-8?q?=20verb=20family=20between=20consecutive=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit randomFullTablePrompt now takes the previous prompt's tense and verb ending and picks the next one to avoid an immediate repeat: when more than one tense is selected the just-shown tense is excluded, and when both -ar and -er/-ir verbs are available the next verb switches family. Both constraints are best-effort — if honouring one would leave no eligible fully-regular combo it is dropped, and the exhaustive "anything eligible at all" guarantee is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/PracticeSessionService.swift | 76 +++++++++++++++---- .../Views/Practice/FullTableView.swift | 5 +- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index 0846ef0..061c037 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -95,7 +95,16 @@ struct PracticeSessionService { return nil } - func randomFullTablePrompt() -> FullTablePrompt? { + /// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe + /// the prompt just shown; when possible the next prompt avoids repeating the + /// same tense back-to-back and switches the verb's ending family + /// (-ar ⇄ -er/-ir) so consecutive rounds feel varied. Both are best-effort: + /// if honouring them would leave no eligible combo, the constraint is + /// dropped rather than dead-ending. + func randomFullTablePrompt( + previousTenseId: String? = nil, + previousEnding: String? = nil + ) -> FullTablePrompt? { let settings = settings() // Full Table is testing the user's grasp of regular conjugation patterns, // not vocabulary recognition. Level filter is intentionally bypassed so @@ -111,25 +120,29 @@ struct PracticeSessionService { let candidateTenseIds = settings.selectionTenseIDs guard !candidateTenseIds.isEmpty else { return nil } - // Cheap path: random sampling. With ~1750 verbs and several hundred - // fully-regular combos this almost always succeeds within a handful - // of attempts. - for _ in 0..<40 { - guard let verb = verbs.randomElement(), - let tenseId = candidateTenseIds.randomElement(), - let tenseInfo = TenseInfo.find(tenseId) else { continue } - if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) { - return prompt - } + let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId) + let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding) + let isConstrained = tenseChoices.count != candidateTenseIds.count + || verbChoices.count != verbs.count + + // Best-effort: random-sample the constrained pool first so consecutive + // prompts vary the tense and the -ar/-er-ir family. + if isConstrained, + let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) { + return prompt + } + + // No constraint, or honouring it found nothing quickly — sample the + // full pool. + if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) { + return prompt } // Guarantee: if any eligible (verb, tense) combo exists in the data we // return one. Only return nil when the user's settings genuinely produce // an empty pool (so the UI can show an error state instead of a blank). - let shuffledVerbs = verbs.shuffled() - let shuffledTenseIds = candidateTenseIds.shuffled() - for verb in shuffledVerbs { - for tenseId in shuffledTenseIds { + for verb in verbs.shuffled() { + for tenseId in candidateTenseIds.shuffled() { guard let tenseInfo = TenseInfo.find(tenseId) else { continue } if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) { return prompt @@ -140,6 +153,39 @@ struct PracticeSessionService { return nil } + /// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo. + /// With ~1750 verbs and several hundred eligible combos this almost always + /// succeeds within a handful of attempts. + private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? { + guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil } + for _ in 0..<40 { + guard let verb = verbs.randomElement(), + let tenseId = tenseIds.randomElement(), + let tenseInfo = TenseInfo.find(tenseId) else { continue } + if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) { + return prompt + } + } + return nil + } + + /// Tense ids with the previous one removed — only when more than one tense + /// is selected and removing it still leaves a choice. + private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] { + guard ids.count > 1, let previous else { return ids } + let filtered = ids.filter { $0 != previous } + return filtered.isEmpty ? ids : filtered + } + + /// Verbs whose ending family (-ar vs -er/-ir) differs from the previous + /// verb's — only when both families are actually present in the pool. + private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] { + guard let previousEnding else { return verbs } + let previousIsAr = (previousEnding == "ar") + let switched = verbs.filter { ($0.ending == "ar") != previousIsAr } + return switched.isEmpty ? verbs : switched + } + /// Returns a `FullTablePrompt` if this verb's forms in the given tense /// follow the regular pattern (per `FullTableEligibility`). Nil otherwise. private func makePromptIfFullyRegular( diff --git a/Conjuga/Conjuga/Views/Practice/FullTableView.swift b/Conjuga/Conjuga/Views/Practice/FullTableView.swift index 8cce9a1..c6356d7 100644 --- a/Conjuga/Conjuga/Views/Practice/FullTableView.swift +++ b/Conjuga/Conjuga/Views/Practice/FullTableView.swift @@ -269,7 +269,10 @@ struct FullTableView: View { cloudContext: cloudModelContext, reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives ) - guard let prompt = service.randomFullTablePrompt() else { + guard let prompt = service.randomFullTablePrompt( + previousTenseId: currentTense?.id, + previousEnding: currentVerb?.ending + ) else { // Genuinely no eligible (verb, tense) combo. Surface a clear error // instead of a blank screen — the previous behaviour silently // rendered an empty header and inputs.