Vocab Practice crash — defensive Dictionary init + correct tense count

Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values
for key: 'imp_tú', triggered the first time a user rated a vocab card
and the in-flight example generation tried to materialise.

Root cause: VerbExampleGenerator.generate() builds a [tenseId: example]
dictionary from the model's output via Dictionary(uniqueKeysWithValues:),
which traps on duplicates. The generator's @Generable schema declares
@Guide(.count(6)) on the examples array, so the LLM is forced to return
exactly 6. The new Vocab Flashcards / Multiple Choice views called
generate(... tenseIds: ["ind_presente"]) — only one tense — which left
the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and
the dictionary init trapped.

Three fixes:

  Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:)
  with first-wins so the generator can never crash regardless of caller
  shape.

  Views/Practice/Vocab/VocabFlashcardPracticeView.swift and
  Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass
  the canonical 6-tense set (VocabExampleTenseIds.canonical, same as
  VerbDetailView uses), then pick the ind_presente example for the
  card. Caches all six in VerbExampleCache as a side effect.

  Views/Verbs/VerbListView.swift — replace empty-string systemImage
  on the Level menu Labels with "circle" so the device console isn't
  spammed with "No symbol named '' found in system symbol set" every
  time the user opens the filter menu.

The crash analysis I gave earlier (CloudKit schema migration of
VerbReviewCard) was wrong — device console shows the real culprit. No
CloudKit-side changes needed; the new model stays in the cloud
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-14 10:02:34 -05:00
parent c890095610
commit 0af8e648fe
4 changed files with 41 additions and 18 deletions
@@ -66,9 +66,16 @@ struct VerbExampleGenerator {
// Map by tenseId and return in the caller's requested order so the UI
// renders a predictable sequence even if the model shuffles its output.
let byTense = Dictionary(uniqueKeysWithValues: response.content.examples.map {
// Use `uniquingKeysWith` defensively the @Generable schema requires
// exactly 6 examples, but if the model duplicates a tenseId (it does
// happen when the caller passes fewer than 6 distinct tenses), the
// strict `uniqueKeysWithValues:` initializer would trap.
let byTense = Dictionary(
response.content.examples.map {
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
})
},
uniquingKeysWith: { first, _ in first }
)
return tenseIds.compactMap { byTense[$0] }
}
@@ -207,16 +207,19 @@ struct VocabFlashcardPracticeView: View {
let english = verb.english
Task {
do {
// The generator's @Generable schema requires exactly 6
// examples; pass the canonical 6-tense set used by
// VerbDetailView, then pick the present-tense one to
// show on the card.
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: ["ind_presente"]
tenseIds: VocabExampleTenseIds.canonical
)
if let first = examples.first {
exampleCache.setExamples(examples, for: verbId)
if currentVerb?.id == verbId {
exampleByVerbId[verbId] = first
}
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {
// Silent the example block just stays hidden.
@@ -238,6 +241,20 @@ struct VocabFlashcardPracticeView: View {
}
}
/// Canonical 6-tense set used by `VerbExampleGenerator`. Its `@Generable`
/// schema requires exactly 6 examples; callers must pass 6 distinct tense
/// IDs so the model has a unique slot for each generated example.
enum VocabExampleTenseIds {
static let canonical: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
}
// MARK: - Pool helper
/// Shared verb-pool fetch used by both vocab flashcard and vocab MC.
@@ -219,13 +219,12 @@ struct VocabMultipleChoicePracticeView: View {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: ["ind_presente"]
tenseIds: VocabExampleTenseIds.canonical
)
if let first = examples.first {
exampleCache.setExamples(examples, for: verbId)
if currentVerb?.id == verbId {
exampleByVerbId[verbId] = first
}
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {}
if generatingExampleForVerbId == verbId {
@@ -97,13 +97,13 @@ struct VerbListView: View {
Button {
setAllLevels(enabled: true)
} label: {
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "")
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
}
ForEach(levels, id: \.self) { level in
Button {
toggleLevel(level)
} label: {
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "")
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
}
}
}
@@ -112,7 +112,7 @@ struct VerbListView: View {
Button {
selectedIrregularity = nil
} label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "")
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
}
ForEach(IrregularityCategory.allCases) { category in
Button {