Files
Spanish/Conjuga/Conjuga/Services/PracticeSessionService.swift
T
Trey T d0582c4ce7 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>
2026-05-17 22:41:18 -05:00

426 lines
16 KiB
Swift

import Foundation
import SharedModels
import SwiftData
struct PracticeSettings: Sendable {
let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
}
var selectionTenseIDs: [String] {
enabledTenses.isEmpty ? TenseID.defaultPracticeIDs : Array(enabledTenses)
}
}
struct PracticeCardLoad {
let verb: Verb
let form: VerbForm
let spans: [IrregularSpan]
let tenseInfo: TenseInfo?
let person: String
}
struct FullTablePrompt {
let verb: Verb
let tenseInfo: TenseInfo
let forms: [VerbForm]
}
struct PracticeSessionService {
let localContext: ModelContext
let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore
init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext
self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext)
}
func settings() -> PracticeSettings {
PracticeSettings(
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
reflexiveBaseInfinitives: reflexiveBaseInfinitives
)
}
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
switch focusMode {
case .weakVerbs:
if let form = pickWeakForm() {
return loadCard(from: form)
}
case .irregularity(let filter):
if let form = pickIrregularForm(filter: filter) {
return loadCard(from: form)
}
case .commonTenses:
if let form = pickCommonTenseForm() {
return loadCard(from: form)
}
case .none:
break
}
if let dueCard = fetchDueCard(excluding: lastVerbId) {
return loadCard(from: dueCard)
}
if let form = pickRandomForm() {
return loadCard(from: form)
}
return nil
}
/// 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
// we draw from the entire verb pool being able to conjugate `hablar`
// regularly transfers to any other regular verb regardless of "level".
// Irregular-category and tense filters still apply via downstream checks.
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(),
settings: settings
)
guard !verbs.isEmpty else { return nil }
let candidateTenseIds = settings.selectionTenseIDs
guard !candidateTenseIds.isEmpty else { return nil }
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).
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
}
}
}
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(
verb: Verb,
tenseId: String,
tenseInfo: TenseInfo
) -> FullTablePrompt? {
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
guard !forms.isEmpty else { return nil }
// Forms must arrive in personIndex order so the regularity array lines
// up. `fetchForms` already sorts them, but assert for safety.
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
let regularities = sorted.map { $0.regularity }
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
}
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
quality: quality,
context: cloudContext,
referenceContext: localContext
)
}
func recordFullTableReview(verbId: Int, tenseId: String, results: [Int: Bool]) -> [Badge] {
ReviewStore.recordFullTableReview(
verbId: verbId,
tenseId: tenseId,
results: results,
context: cloudContext,
referenceContext: localContext
)
}
func loadCard(from reviewCard: ReviewCard) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: reviewCard.verbId),
let form = referenceStore.fetchForm(
verbId: reviewCard.verbId,
tenseId: reviewCard.tenseId,
personIndex: reviewCard.personIndex
) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
func loadCard(from form: VerbForm) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: form.verbId) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
/// verb-id set to IDs whose infinitive is in the curated list.
/// No-op otherwise.
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return ids
}
let matching = ids.filter { id in
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
}
return matching
}
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return verbs
}
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans(
verbId: form.verbId,
tenseId: form.tenseId,
personIndex: form.personIndex
)
let person = TenseInfo.persons.indices.contains(form.personIndex)
? TenseInfo.persons[form.personIndex]
: ""
return PracticeCardLoad(
verb: verb,
form: form,
spans: spans,
tenseInfo: TenseInfo.find(form.tenseId),
person: person
)
}
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings()
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let now = Date()
var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
)
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
let cards = (try? cloudContext.fetch(descriptor)) ?? []
let eligible = cards.filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
// Prefer a card from a different verb than the last one shown.
// Fall back to the same verb only if it's the sole due card.
if let lastVerbId {
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
return different
}
}
return eligible.first
}
private func pickWeakForm() -> VerbForm? {
let settings = settings()
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
)
let cards = ((try? cloudContext.fetch(descriptor)) ?? []).filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
guard let card = cards.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: card.verbId,
tenseId: card.tenseId,
personIndex: card.personIndex
)
}
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings()
// Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: []
),
settings: settings
)
let typeRange: ClosedRange<Int>
switch filter {
case .spelling:
typeRange = 100...199
case .stemChange:
typeRange = 200...299
case .uniqueIrregular:
typeRange = 300...399
}
var descriptor = FetchDescriptor<IrregularSpan>(
predicate: #Predicate<IrregularSpan> { span in
span.spanType >= typeRange.lowerBound &&
span.spanType <= typeRange.upperBound
}
)
descriptor.fetchLimit = 500
let spans = ((try? localContext.fetch(descriptor)) ?? []).filter { span in
allowedVerbIds.contains(span.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
(settings.showVosotros || span.personIndex != 4)
}
guard let span = spans.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: span.verbId,
tenseId: span.tenseId,
personIndex: span.personIndex
)
}
private func pickCommonTenseForm() -> VerbForm? {
let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
coreTenseIDs.contains(form.tenseId) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
private func pickRandomForm() -> VerbForm? {
let settings = settings()
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(form.tenseId)) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
}