Full Table — vary tense and verb family between consecutive prompts

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-17 22:41:18 -05:00
parent 26ce662c60
commit d0582c4ce7
2 changed files with 65 additions and 16 deletions
@@ -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(
@@ -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.