diff --git a/README.md b/README.md index 2b1118f..1ba26bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md index 5047a14..cdec0ac 100644 --- a/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md +++ b/Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md @@ -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. diff --git a/Sources/FlashcardKit/FlashcardLogging.swift b/Sources/FlashcardKit/FlashcardLogging.swift index f0d712f..bb49930 100644 --- a/Sources/FlashcardKit/FlashcardLogging.swift +++ b/Sources/FlashcardKit/FlashcardLogging.swift @@ -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)) } diff --git a/Sources/FlashcardKit/ThreeChoiceRoundError.swift b/Sources/FlashcardKit/ThreeChoiceRoundError.swift new file mode 100644 index 0000000..d08887a --- /dev/null +++ b/Sources/FlashcardKit/ThreeChoiceRoundError.swift @@ -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) +} diff --git a/Sources/FlashcardKit/ThreeChoiceRoundPlan.swift b/Sources/FlashcardKit/ThreeChoiceRoundPlan.swift new file mode 100644 index 0000000..0ed8f49 --- /dev/null +++ b/Sources/FlashcardKit/ThreeChoiceRoundPlan.swift @@ -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() + 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" + } + } +} diff --git a/Sources/FlashcardKit/ThreeChoiceSession.swift b/Sources/FlashcardKit/ThreeChoiceSession.swift index 3318eb2..d82fe9d 100644 --- a/Sources/FlashcardKit/ThreeChoiceSession.swift +++ b/Sources/FlashcardKit/ThreeChoiceSession.swift @@ -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. @@ -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 ) @@ -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 ) } } diff --git a/Tests/FlashcardKitTests/FlashcardLoggingTests.swift b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift index d8fa9be..12660c4 100644 --- a/Tests/FlashcardKitTests/FlashcardLoggingTests.swift +++ b/Tests/FlashcardKitTests/FlashcardLoggingTests.swift @@ -84,6 +84,36 @@ struct FlashcardLoggingTests { #expect(messages.allSatisfy { !$0.contains(forbiddenValue) }) } } + + @Test("Round-plan messages expose only stable result classes") + func roundPlanMessagePrivacy() { + let messages = [ + FlashcardLogging.roundCreatedMessage(), + FlashcardLogging.roundRejectedMessage(reason: "duplicate-candidate"), + FlashcardLogging.roundEvaluationAcceptedMessage(outcome: .correct), + FlashcardLogging.roundEvaluationAcceptedMessage(outcome: .incorrect), + FlashcardLogging.roundEvaluationAcceptedMessage(outcome: .expired), + FlashcardLogging.roundEvaluationRejectedMessage(reason: "stale-round"), + ] + + #expect(messages[0] == "round created | choices=3") + #expect(messages[1] == "round rejected | reason=duplicate-candidate") + #expect(messages[2] == "round evaluated | outcome=correct") + #expect(messages[3] == "round evaluated | outcome=incorrect") + #expect(messages[4] == "round evaluated | outcome=expired") + #expect(messages[5] == "round evaluation rejected | reason=stale-round") + for forbiddenValue in [ + "prompt", + "answer", + "asset-reference", + "00000000-0000-0000-0000-000000000001", + "round-id=7", + "choice-id=2", + "seed=42", + ] { + #expect(messages.allSatisfy { !$0.contains(forbiddenValue) }) + } + } } extension FlashcardLoggingTests { diff --git a/Tests/FlashcardKitTests/ThreeChoiceRoundPlanTests.swift b/Tests/FlashcardKitTests/ThreeChoiceRoundPlanTests.swift new file mode 100644 index 0000000..5feb4ac --- /dev/null +++ b/Tests/FlashcardKitTests/ThreeChoiceRoundPlanTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing + +@testable import FlashcardKit + +@Suite("Three-choice round plans") +struct ThreeChoiceRoundPlanTests { + @Test("A separate correct answer and two distractors produce one valid round") + func separateCorrectAnswer() throws { + let correct = try content("B") + let plan = try makePlan( + correctAnswer: correct, + candidates: [content("A"), content("C")] + ) + + #expect(plan.round.choices.count == 3) + #expect(Set(plan.round.choices.map(\.content)).count == 3) + #expect(plan.round.choices.count { $0.content == correct } == 1) + } + + @Test("A closed candidate pool may contain the correct answer once") + func closedCandidatePool() throws { + let correct = try content("B") + let plan = try makePlan( + correctAnswer: correct, + candidates: [content("A"), correct, content("C")] + ) + + #expect(Set(plan.round.choices.map(\.content)) == Set(try [content("A"), correct, content("C")])) + #expect(plan.round.choices.count { $0.content == correct } == 1) + } + + @Test("Candidate selection and ordering are deterministic") + func deterministicConstruction() throws { + let candidates = try [content("A"), content("C"), content("D"), content("E")] + let first = try makePlan(correctAnswer: content("B"), candidates: candidates, seed: 42) + let second = try makePlan(correctAnswer: content("B"), candidates: candidates, seed: 42) + + #expect(first.round == second.round) + #expect(first.round.choices.count == 3) + } + + @Test("Round identity does not influence deterministic choice ordering") + func identityIsOrthogonalToSeed() throws { + let candidates = try [content("A"), content("C"), content("D")] + let first = try makePlan( + id: 7, + correctAnswer: content("B"), + candidates: candidates, + seed: 3 + ) + let second = try makePlan( + id: 99, + correctAnswer: content("B"), + candidates: candidates, + seed: 3 + ) + + #expect(first.round.id == 7) + #expect(second.round.id == 99) + #expect(first.round.choices == second.round.choices) + } + + @Test("Repeated correct-answer candidates are rejected") + func multipleCorrectCandidates() throws { + let correct = try content("B") + + #expect(throws: ThreeChoiceRoundError.multipleCorrectAnswerCandidates(count: 2)) { + try makePlan( + correctAnswer: correct, + candidates: [correct, content("A"), correct, content("C")] + ) + } + } + + @Test("Repeated non-correct candidates are rejected") + func duplicateCandidate() throws { + let duplicate = try content("A") + + #expect(throws: ThreeChoiceRoundError.duplicateCandidateAnswer(duplicate)) { + try makePlan( + correctAnswer: content("B"), + candidates: [duplicate, content("C"), duplicate] + ) + } + } + + @Test("Insufficient candidate pools are rejected with their visible-answer count") + func insufficientCandidates() throws { + let correct = try content("B") + + #expect(throws: ThreeChoiceRoundError.insufficientDistinctAnswers(minimum: 3, actual: 2)) { + try makePlan(correctAnswer: correct, candidates: [content("A")]) + } + #expect(throws: ThreeChoiceRoundError.insufficientDistinctAnswers(minimum: 3, actual: 2)) { + try makePlan(correctAnswer: correct, candidates: [content("A"), correct]) + } + } + + @Test("Valid selections and expiry produce answer-key evaluations") + func evaluation() throws { + let correct = try content("B") + let plan = try makePlan( + correctAnswer: correct, + candidates: [content("A"), content("C")] + ) + let correctChoice = try #require(plan.round.choices.first { $0.content == correct }) + let incorrectChoice = try #require(plan.round.choices.first { $0.content != correct }) + + let correctEvaluation = try plan.evaluate( + .selection(choiceID: correctChoice.id), + forRoundID: plan.round.id + ) + let incorrectEvaluation = try plan.evaluate( + .selection(choiceID: incorrectChoice.id), + forRoundID: plan.round.id + ) + let expiredEvaluation = try plan.evaluate(.expired, forRoundID: plan.round.id) + + #expect(correctEvaluation.outcome == .correct) + #expect(correctEvaluation.correctChoiceID == correctChoice.id) + #expect(incorrectEvaluation.outcome == .incorrect) + #expect(incorrectEvaluation.correctChoiceID == correctChoice.id) + #expect(expiredEvaluation.outcome == .expired) + #expect(expiredEvaluation.selectedChoiceID == nil) + #expect(expiredEvaluation.correctChoiceID == correctChoice.id) + } + + @Test("Evaluation rejects stale rounds before unknown choices") + func evaluationValidation() throws { + let plan = try makePlan( + correctAnswer: content("B"), + candidates: [content("A"), content("C")] + ) + + #expect(throws: ThreeChoiceRoundError.staleRound(expected: 7, received: 99)) { + try plan.evaluate(.selection(choiceID: 99), forRoundID: 99) + } + #expect(throws: ThreeChoiceRoundError.invalidChoice(99)) { + try plan.evaluate(.selection(choiceID: 99), forRoundID: plan.round.id) + } + } + + @Test("The plan is Sendable and emitted values retain their value contracts") + func valueContracts() throws { + let plan = try makePlan( + correctAnswer: content("B"), + candidates: [content("A"), content("C")] + ) + let evaluation = try plan.evaluate(.expired, forRoundID: plan.round.id) + + requireSendable(plan) + requireValueContract(plan.round) + requireValueContract(evaluation) + } + + // MARK: - Private + + private func content(_ value: String) throws -> FlashcardContent { + try FlashcardContent(text: value) + } + + private func makePlan( + id: Int = 7, + correctAnswer: FlashcardContent, + candidates: [FlashcardContent], + seed: UInt64 = 1 + ) throws -> ThreeChoiceRoundPlan { + try ThreeChoiceRoundPlan( + id: id, + cardID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + prompt: content("prompt"), + correctAnswer: correctAnswer, + candidates: candidates, + seed: seed + ) + } + + private func requireSendable(_ value: Value) { + _ = value + } + + private func requireValueContract(_ value: Value) { + _ = value + } +}