Fix Full Table eligibility — accept ordinary verbs, reject orto

The eligibility filter required every form's regularity tag to equal
"regular", but the data uses four labels:
  - regular   (179 forms — curated paradigm verbs)
  - ordinary  (50,992 forms — pattern-following verbs like hablar, comer)
  - irregular (8,653)
  - orto      (176 — orthographic spelling changes like busqué)

Result was a 27-combo eligible pool, ~26 of which were -ir verbs in
present tense — every Full Table prompt landed on the same handful of
verbs.

Pulled the rule into a SharedModels function (FullTableEligibility) so
it's testable in isolation. Accepts "regular" + "ordinary" (both mean
"follows the pattern"); rejects "irregular" and "orto". 9 unit tests
cover the matrix including edge cases (incomplete forms, mixed labels,
unknown values).

PracticeSessionService.makePromptIfFullyRegular now delegates to
FullTableEligibility, sorting forms by personIndex so the regularity
array lines up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-26 11:26:34 -05:00
parent dce2cc1f51
commit 90aea92fba
3 changed files with 113 additions and 4 deletions
@@ -0,0 +1,31 @@
import Foundation
/// Decides whether a (verb, tense) combo is eligible for the Full Table
/// practice mode. Pulled out of `PracticeSessionService` so the rule can be
/// unit tested in isolation.
public enum FullTableEligibility {
/// `VerbForm.regularity` values understood by the data set.
public enum Regularity: String, Sendable {
case regular // paradigm-teaching verbs (small curated set)
case ordinary // pattern-following verbs (the bulk of the dataset)
case irregular
case orto // orthographic spelling change (e.g. busqué)
}
/// Returns true when the given (verb, tense) combo is a candidate for
/// Full Table i.e. it follows the regular pattern with no irregularity
/// and no orthographic spelling change.
///
/// The conjugation must be present in all 6 person slots; missing forms
/// disqualify the combo.
///
/// Accepted: `regular` (paradigm-teaching verbs) and `ordinary`
/// (pattern-following verbs `hablar`, `comer`, `vivir`, etc.).
/// Rejected: `irregular` and `orto` (orthographic spelling changes).
public static func isFullyRegular(regularities: [String]) -> Bool {
guard regularities.count == 6 else { return false }
let acceptable: Set<String> = ["regular", "ordinary"]
return regularities.allSatisfy { acceptable.contains($0) }
}
}
@@ -0,0 +1,74 @@
import Testing
@testable import SharedModels
@Suite("FullTableEligibility")
struct FullTableEligibilityTests {
// MARK: - Should be eligible
@Test("all-ordinary verb (e.g. hablar present) is eligible")
func allOrdinary() {
// hablar present: hablo / hablas / habla / hablamos / habláis / hablan
let r = Array(repeating: "ordinary", count: 6)
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("all-regular paradigm verb (e.g. vivir present) is eligible")
func allRegular() {
// vivir present is tagged "regular" in the curated subset
let r = Array(repeating: "regular", count: 6)
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("mixed regular + ordinary across persons is eligible")
func mixedRegularOrdinary() {
// The data never actually mixes these, but it shouldn't matter if it
// did both labels mean "follows the regular pattern".
let r = ["regular", "ordinary", "regular", "ordinary", "regular", "ordinary"]
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
// MARK: - Should NOT be eligible
@Test("any irregular form rejects the combo")
func anyIrregular() {
var r = Array(repeating: "ordinary", count: 6)
r[3] = "irregular"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("orthographic spelling change rejects the combo (excluded by design)")
func anyOrto() {
// buscar preterite: yo "busqué" carries an orto tag for the cqu shift.
// Per design choice, orto verbs are NOT eligible for Full Table.
var r = Array(repeating: "ordinary", count: 6)
r[0] = "orto"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("all-irregular rejects")
func allIrregular() {
let r = Array(repeating: "irregular", count: 6)
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
// MARK: - Edge cases
@Test("incomplete forms (fewer than 6 persons) are rejected")
func incompleteForms() {
let r = Array(repeating: "ordinary", count: 5)
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("empty input is rejected")
func empty() {
#expect(!FullTableEligibility.isFullyRegular(regularities: []))
}
@Test("unknown regularity value is rejected (defensive default)")
func unknownValue() {
var r = Array(repeating: "ordinary", count: 6)
r[2] = "garbage_value"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
}