Decoupling Selection Logic from Presentation
The new Worksheets feature in our Music Theory Learning app generates printable PDF worksheets from the same pool of automatically generated exercises that powers the interactive Daily Challenge.
The interesting part from an engineering perspective is that both features use the same task-selection engine despite having fundamentally different output formats and UI requirements. This keeps content generation and difficulty selection independent from presentation and makes the underlying task infrastructure reusable across features.
`ChallengeCandidateSelector.selectTasks` provides a single, presentation-independent task selection layer for different consumers of the app's music-theory content.
The function operates on a precomputed pool of `ChallengeTaskCandidate` values and selects a batch based on a `ChallengeDifficulty`. It has no knowledge of the UI, rendering target, or consumer feature. The Daily Challenge uses it to generate interactive quiz tasks, while the Worksheets feature uses the same selection mechanism to populate printable PDF pages.
The selector can optionally equalize the selected tasks across the complete difficulty range. With `equalized: true`, the result is distributed across the available difficulty scores rather than being dominated by candidates from a single score band. This is particularly useful for worksheets, where a page should provide a balanced progression of exercises.
Both consumers provide their own candidate pool and difficulty configuration, but the selection algorithm remains unchanged:
protocol ChallengeTaskCandidate {
var uniqueKey: String { get }
var difficultyScore: Double { get }
}
enum ChallengeCandidateSelector {
static func selectTasks < T: ChallengeTaskCandidate > (
fullPool: [T],
difficulty: ChallengeDifficulty,
count: Int,
equalized: Bool = false
) -> [T] {
/* ... */
}
}
The Daily Challenge can use the selector directly for its interactive tasks:
let batch = ChallengeCandidateSelector.selectTasks(
fullPool: NoteCandidatePool.all,
difficulty: difficulty,
count: 5,
equalized: true
)
Worksheets use the same generic selection engine, converting their own difficulty representation to `ChallengeDifficulty` before selecting the required number of tasks:
let noteTasks = ChallengeCandidateSelector.selectTasks(
fullPool: NoteCandidatePool.all,
difficulty: WorksheetDifficultyConverter.toChallengeDifficulty(difficulty),
count: taskCount,
equalized: true
)
The generic constraint is intentionally minimal. `selectTasks` only requires `uniqueKey` and `difficultyScore`, so the selector does not depend on the concrete task type or its content-specific properties. New candidate types can therefore participate in the same selection pipeline without modifications to the selection logic.
The same applies to consumers: adding another presentation or delivery mechanism does not require changes to the task-selection layer. Content generation, difficulty filtering, task balancing, and presentation remain separate responsibilities.
