Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ if let attempt = progressiveSession.currentAttempt {

Selection is canonicalized by card identity and then seeded-shuffled. Every selected card begins at stage zero. Correct outcomes promote or complete a card; incorrect and expired outcomes retain the current stage. Unfinished cards requeue at the tail, except that a sole unfinished card necessarily repeats immediately. Attempt IDs increase monotonically within the session, and `generatedAttempts` grows with retries and promotions rather than describing a fixed total. Three-choice construction and pronunciation evaluation remain outside the progression engine.

Use `ThreeChoiceRoundPlan` to construct and evaluate one deterministic three-choice interaction from an explicit answer pool:

```swift
let plan = try ThreeChoiceRoundPlan(
id: 7,
cardID: card.id,
prompt: stage.prompt,
correctAnswer: stage.answer,
candidates: candidateAnswers,
seed: 42
)

let round = plan.round
let evaluation = try plan.evaluate(
.selection(choiceID: round.choices[0].id),
forRoundID: round.id
)
```

The candidate pool may omit the authoritative correct answer or contain it exactly once; FlashcardKit contributes that answer exactly once to the visible round. Repeated correct-answer candidates and duplicate non-correct candidates are rejected rather than silently deduplicated. The ordered pool and seed determine choice ordering, while round identity is copied through without affecting randomness. Consumers can compose a progressive attempt with this round mechanism, but FlashcardKit does not automatically couple progression to three-choice evaluation.

The session plan is reproducible for identical cards, configuration, and seed. Each round exposes one correct answer and two distinct distractors; a host can submit a selected choice or explicit expiry. Presentation, timers, persistence frameworks, image resolution, vocabulary acquisition, and spaced repetition remain outside the package boundary.

## Installation
Expand Down
23 changes: 23 additions & 0 deletions Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ The session canonicalizes cards by identity before seeded selection, begins each

``ThreeChoiceSession`` remains a separate finite stage-zero engine. Progressive sessions consume only host-decided correct, incorrect, or expired outcomes; they do not construct choices, evaluate pronunciation, own timers, or assign scores.

Use ``ThreeChoiceRoundPlan`` to construct and evaluate one deterministic three-choice interaction from an explicit candidate pool:

```swift
let plan = try ThreeChoiceRoundPlan(
id: 7,
cardID: card.id,
prompt: stage.prompt,
correctAnswer: stage.answer,
candidates: candidateAnswers,
seed: 42
)

let round = plan.round
let evaluation = try plan.evaluate(
.selection(choiceID: round.choices[0].id),
forRoundID: round.id
)
```

The candidate pool may omit the authoritative correct answer or contain it exactly once. FlashcardKit contributes that answer exactly once to the visible round. Repeated correct-answer candidates and duplicate non-correct candidates produce ``ThreeChoiceRoundError`` instead of being silently collapsed. Candidate order and the supplied seed determine the visible choice order; the round identity is copied to emitted values without changing randomness.

These APIs stay deliberately separate: ``ThreeChoiceRoundPlan`` constructs and evaluates one response-mode-specific round, ``ThreeChoiceSession`` retains its existing finite stage-zero behavior, and ``ProgressiveFlashcardSession`` owns response-mode-agnostic stage progression. A consumer may compose an attempt with a round mechanism, but FlashcardKit does not couple the two engines automatically.

The package does not resolve asset references or own presentation, persistence frameworks, vocabulary acquisition, or scheduling policy.

Use ``ThreeChoiceSession`` to create a finite seeded plan with one correct answer and two distinct distractors per round. The host owns any timer and submits either ``ThreeChoiceResponse/selection(choiceID:)`` or ``ThreeChoiceResponse/expired`` against the exact visible round identity.
Expand Down
32 changes: 32 additions & 0 deletions Sources/FlashcardKit/FlashcardLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,38 @@ enum FlashcardLogging {
log(level: .error, "response rejected | reason=\(reason)")
}

static func roundCreated() {
log(roundCreatedMessage())
}

static func roundRejected(reason: String) {
log(level: .error, roundRejectedMessage(reason: reason))
}

static func roundEvaluationAccepted(outcome: ThreeChoiceEvaluation.Outcome) {
log(roundEvaluationAcceptedMessage(outcome: outcome))
}

static func roundEvaluationRejected(reason: String) {
log(level: .error, roundEvaluationRejectedMessage(reason: reason))
}

static func roundCreatedMessage() -> String {
"round created | choices=3"
}

static func roundRejectedMessage(reason: String) -> String {
"round rejected | reason=\(reason)"
}

static func roundEvaluationAcceptedMessage(outcome: ThreeChoiceEvaluation.Outcome) -> String {
"round evaluated | outcome=\(outcome.token)"
}

static func roundEvaluationRejectedMessage(reason: String) -> String {
"round evaluation rejected | reason=\(reason)"
}

static func progressiveSessionCreated(cards: Int) {
log(progressiveSessionCreatedMessage(cards: cards))
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/FlashcardKit/ThreeChoiceRoundError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// Construction and evaluation failures for one reusable three-choice round.
public enum ThreeChoiceRoundError: Error, Sendable, Equatable {
/// The explicit pool repeats one non-correct candidate answer.
case duplicateCandidateAnswer(FlashcardContent)

/// Correct answer plus distinct distractors cannot produce three visible choices.
case insufficientDistinctAnswers(minimum: Int, actual: Int)

/// The selected choice does not belong to this round.
case invalidChoice(Int)

/// The explicit pool contains the authoritative correct answer more than once.
case multipleCorrectAnswerCandidates(count: Int)

/// Evaluation targeted a round other than this plan's round.
case staleRound(expected: Int, received: Int)
}
176 changes: 176 additions & 0 deletions Sources/FlashcardKit/ThreeChoiceRoundPlan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
public import Foundation

/// An immutable deterministic three-choice round with its hidden answer key.
public struct ThreeChoiceRoundPlan: Sendable {
enum EvaluationError: Error {
case invalidChoice(Int)
case staleRound(expected: Int, received: Int)
}

/// Presentation-ready round containing exactly three distinct choices.
public let round: ThreeChoiceRound

private let correctChoiceID: Int

/// Creates one deterministic round from an authoritative answer and explicit candidate pool.
///
/// The candidate pool may omit the correct answer or contain it exactly once. Repeated correct answers and duplicate non-correct candidates are rejected rather than silently deduplicated.
///
/// - Parameters:
/// - id: Host-supplied identity copied to the round without influencing ordering.
/// - cardID: Stable host-authored identity associated with the prompt.
/// - prompt: Content presented for recall.
/// - correctAnswer: Authoritative answer included exactly once in the visible choices.
/// - candidates: Ordered explicit pool from which distinct distractors are selected.
/// - seed: Seed controlling deterministic distractor and choice ordering.
/// - Throws: ``ThreeChoiceRoundError`` when candidates cannot produce exactly three distinct choices.
public init(
id: Int,
cardID: UUID,
prompt: FlashcardContent,
correctAnswer: FlashcardContent,
candidates: [FlashcardContent],
seed: UInt64
) throws {
let distractors: [FlashcardContent]
do {
distractors = try Self.validatedDistractors(
candidates,
correctAnswer: correctAnswer
)
} catch let error as ThreeChoiceRoundError {
FlashcardLogging.roundRejected(reason: error.token)
throw error
}

var random = DeterministicRandom(seed: seed)
self.init(
id: id,
cardID: cardID,
prompt: prompt,
correctAnswer: correctAnswer,
validatedDistractors: distractors,
random: &random
)
FlashcardLogging.roundCreated()
}

/// Evaluates a selected choice or expiry against this exact round.
///
/// - Parameters:
/// - response: Visible choice selection or host-reported expiry.
/// - roundID: Identity of the round that produced the response.
/// - Returns: The accepted response outcome and answer key.
/// - Throws: ``ThreeChoiceRoundError/staleRound(expected:received:)`` or ``ThreeChoiceRoundError/invalidChoice(_:)`` when the response does not belong to this round.
public func evaluate(
_ response: ThreeChoiceResponse,
forRoundID roundID: Int
) throws -> ThreeChoiceEvaluation {
do {
let evaluation = try evaluateWithoutLogging(response, forRoundID: roundID)
FlashcardLogging.roundEvaluationAccepted(outcome: evaluation.outcome)
return evaluation
} catch EvaluationError.staleRound(let expected, let received) {
FlashcardLogging.roundEvaluationRejected(reason: "stale-round")
throw ThreeChoiceRoundError.staleRound(expected: expected, received: received)
} catch EvaluationError.invalidChoice(let choiceID) {
FlashcardLogging.roundEvaluationRejected(reason: "invalid-choice")
throw ThreeChoiceRoundError.invalidChoice(choiceID)
}
}

init(
id: Int,
cardID: UUID,
prompt: FlashcardContent,
correctAnswer: FlashcardContent,
validatedDistractors: [FlashcardContent],
random: inout DeterministicRandom
) {
var distractors = validatedDistractors
random.shuffle(&distractors)

var answers = [correctAnswer] + distractors.prefix(2)
random.shuffle(&answers)
correctChoiceID = answers.firstIndex(of: correctAnswer)!
round = ThreeChoiceRound(
id: id,
cardID: cardID,
prompt: prompt,
choices: answers.enumerated().map { ThreeChoice(id: $0, content: $1) }
)
}

func evaluateWithoutLogging(
_ response: ThreeChoiceResponse,
forRoundID roundID: Int
) throws(EvaluationError) -> ThreeChoiceEvaluation {
guard roundID == round.id else {
throw EvaluationError.staleRound(expected: round.id, received: roundID)
}

switch response {
case .selection(let choiceID):
guard round.choices.contains(where: { $0.id == choiceID }) else {
throw EvaluationError.invalidChoice(choiceID)
}
return ThreeChoiceEvaluation(
roundID: round.id,
outcome: choiceID == correctChoiceID ? .correct : .incorrect,
selectedChoiceID: choiceID,
correctChoiceID: correctChoiceID
)
case .expired:
return ThreeChoiceEvaluation(
roundID: round.id,
outcome: .expired,
selectedChoiceID: nil,
correctChoiceID: correctChoiceID
)
}
}

// MARK: - Private

private static func validatedDistractors(
_ candidates: [FlashcardContent],
correctAnswer: FlashcardContent
) throws -> [FlashcardContent] {
let correctCandidateCount = candidates.count { $0 == correctAnswer }
guard correctCandidateCount <= 1 else {
throw ThreeChoiceRoundError.multipleCorrectAnswerCandidates(
count: correctCandidateCount
)
}

var seen = Set<FlashcardContent>()
var distractors: [FlashcardContent] = []
for candidate in candidates where candidate != correctAnswer {
guard seen.insert(candidate).inserted else {
throw ThreeChoiceRoundError.duplicateCandidateAnswer(candidate)
}
distractors.append(candidate)
}

let distinctAnswerCount = 1 + distractors.count
guard distinctAnswerCount >= 3 else {
throw ThreeChoiceRoundError.insufficientDistinctAnswers(
minimum: 3,
actual: distinctAnswerCount
)
}
return distractors
}
}

extension ThreeChoiceRoundError {
var token: String {
switch self {
case .duplicateCandidateAnswer: "duplicate-candidate"
case .insufficientDistinctAnswers: "insufficient-distinct-answers"
case .invalidChoice: "invalid-choice"
case .multipleCorrectAnswerCandidates: "multiple-correct-candidates"
case .staleRound: "stale-round"
}
}
}
71 changes: 22 additions & 49 deletions Sources/FlashcardKit/ThreeChoiceSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import Foundation

/// A deterministic finite recall session with exactly three answers per round.
public struct ThreeChoiceSession: Sendable {
private struct PlannedRound: Sendable {
let correctChoiceID: Int
let round: ThreeChoiceRound
}

private let plan: [PlannedRound]
private let plan: [ThreeChoiceRoundPlan]
private var currentIndex = 0

/// The round awaiting a response, or `nil` after completion.
Expand Down Expand Up @@ -79,38 +74,23 @@ public struct ThreeChoiceSession: Sendable {
FlashcardLogging.responseRejected(reason: "session-complete")
throw ThreeChoiceSessionError.sessionComplete
}
guard plannedRound.round.id == roundID else {
FlashcardLogging.responseRejected(reason: "stale-round")
throw ThreeChoiceSessionError.staleRound(
expected: plannedRound.round.id,
received: roundID
let evaluation: ThreeChoiceEvaluation
do {
evaluation = try plannedRound.evaluateWithoutLogging(
response,
forRoundID: roundID
)
}

let selectedChoiceID: Int?
let outcome: ThreeChoiceEvaluation.Outcome
switch response {
case .selection(let choiceID):
guard plannedRound.round.choices.contains(where: { $0.id == choiceID }) else {
FlashcardLogging.responseRejected(reason: "invalid-choice")
throw ThreeChoiceSessionError.invalidChoice(choiceID)
}
selectedChoiceID = choiceID
outcome = choiceID == plannedRound.correctChoiceID ? .correct : .incorrect
case .expired:
selectedChoiceID = nil
outcome = .expired
} catch ThreeChoiceRoundPlan.EvaluationError.staleRound(let expected, let received) {
FlashcardLogging.responseRejected(reason: "stale-round")
throw ThreeChoiceSessionError.staleRound(expected: expected, received: received)
} catch ThreeChoiceRoundPlan.EvaluationError.invalidChoice(let choiceID) {
FlashcardLogging.responseRejected(reason: "invalid-choice")
throw ThreeChoiceSessionError.invalidChoice(choiceID)
}

currentIndex += 1
let evaluation = ThreeChoiceEvaluation(
roundID: roundID,
outcome: outcome,
selectedChoiceID: selectedChoiceID,
correctChoiceID: plannedRound.correctChoiceID
)
FlashcardLogging.responseAccepted(
outcome: outcome,
outcome: evaluation.outcome,
completed: currentIndex,
total: plan.count
)
Expand Down Expand Up @@ -148,27 +128,20 @@ public struct ThreeChoiceSession: Sendable {
promptCards: [Flashcard],
allCards: [Flashcard],
random: inout DeterministicRandom
) -> [PlannedRound] {
) -> [ThreeChoiceRoundPlan] {
promptCards.enumerated().map { roundID, card in
var seenAnswers = Set([card.answer])
var distractors = allCards.compactMap { candidate -> FlashcardContent? in
let distractors = allCards.compactMap { candidate -> FlashcardContent? in
guard seenAnswers.insert(candidate.answer).inserted else { return nil }
return candidate.answer
}
random.shuffle(&distractors)

var answers = [card.answer] + distractors.prefix(2)
random.shuffle(&answers)
let correctChoiceID = answers.firstIndex(of: card.answer)!
let choices = answers.enumerated().map { ThreeChoice(id: $0, content: $1) }
return PlannedRound(
correctChoiceID: correctChoiceID,
round: ThreeChoiceRound(
id: roundID,
cardID: card.id,
prompt: card.prompt,
choices: choices
)
return ThreeChoiceRoundPlan(
id: roundID,
cardID: card.id,
prompt: card.prompt,
correctAnswer: card.answer,
validatedDistractors: distractors,
random: &random
)
}
}
Expand Down
Loading