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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,29 @@ if let round = session.currentRound {
}
```

The simple initializer remains the shortest path for one-stage cards. Stage-aware hosts can provide a nonempty ordered sequence with stable, unique stage identifiers. The current `ThreeChoiceSession` continues to use each card's first stage; progressive stage scheduling is a separate API step.
The simple initializer remains the shortest path for one-stage cards. Stage-aware hosts can provide a nonempty ordered sequence with stable, unique stage identifiers. The current `ThreeChoiceSession` continues to use each card's first stage.

Use `ProgressiveFlashcardSession` when a selected card must advance through every ordered stage independently of how the host evaluates it:

```swift
var progressiveSession = try ProgressiveFlashcardSession(
cards: cards,
configuration: ProgressiveFlashcardSessionConfiguration(
seed: 42,
cardCount: 5
)
)

if let attempt = progressiveSession.currentAttempt {
// The host decides how this stage is evaluated.
let evaluation = try progressiveSession.submit(
.correct,
forAttemptID: attempt.id
)
}
```

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.

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.

Expand Down
26 changes: 25 additions & 1 deletion Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,31 @@ let stagedCard = try Flashcard(
)
```

Stage order is significant and preserved by `Codable`. ``FlashcardError`` reports missing stages and duplicate stage identifiers. Current ``ThreeChoiceSession`` behavior remains one-stage-compatible by reading each card's first stage; stage progression is intentionally separate from the card value model.
Stage order is significant and preserved by `Codable`. ``FlashcardError`` reports missing stages and duplicate stage identifiers. Current ``ThreeChoiceSession`` behavior remains one-stage-compatible by reading each card's first stage.

Use ``ProgressiveFlashcardSession`` to select a deterministic subset of cards and advance each card through all of its ordered stages without coupling progression to the host's response mechanism:

```swift
var progressiveSession = try ProgressiveFlashcardSession(
cards: cards,
configuration: ProgressiveFlashcardSessionConfiguration(
seed: 42,
cardCount: 5
)
)

if let attempt = progressiveSession.currentAttempt {
// The host decides how this stage is evaluated.
let evaluation = try progressiveSession.submit(
.correct,
forAttemptID: attempt.id
)
}
```

The session canonicalizes cards by identity before seeded selection, begins each selected card at stage zero, and gives every presentation opportunity a monotonic session-scoped attempt identity. Correct outcomes promote a card or complete its final stage; incorrect and expired outcomes retain the same stage. Every unfinished card is appended to the queue tail, so it reappears after other unfinished cards. A sole unfinished card necessarily repeats immediately. ``ProgressiveFlashcardProgress/generatedAttempts`` grows as retries and promotions issue new attempts and is not a fixed session total.

``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.

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

Expand Down
79 changes: 79 additions & 0 deletions Sources/FlashcardKit/FlashcardLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,65 @@ enum FlashcardLogging {
log(level: .error, "response rejected | reason=\(reason)")
}

static func progressiveSessionCreated(cards: Int) {
log(progressiveSessionCreatedMessage(cards: cards))
}

static func progressiveSessionRejected(reason: String) {
log(level: .error, progressiveSessionRejectedMessage(reason: reason))
}

static func progressiveAttemptAccepted(
outcome: ProgressiveFlashcardEvaluation.Outcome,
transition: ProgressiveFlashcardEvaluation.Transition,
completedCards: Int,
selectedCards: Int,
generatedAttempts: Int
) {
log(
progressiveAttemptAcceptedMessage(
outcome: outcome,
transition: transition,
completedCards: completedCards,
selectedCards: selectedCards,
generatedAttempts: generatedAttempts
))
}

static func progressiveAttemptRejected(reason: String) {
log(level: .error, progressiveAttemptRejectedMessage(reason: reason))
}

static func progressiveSessionCompleted(cards: Int, attempts: Int) {
log(progressiveSessionCompletedMessage(cards: cards, attempts: attempts))
}

static func progressiveAttemptAcceptedMessage(
outcome: ProgressiveFlashcardEvaluation.Outcome,
transition: ProgressiveFlashcardEvaluation.Transition,
completedCards: Int,
selectedCards: Int,
generatedAttempts: Int
) -> String {
"progressive attempt accepted | outcome=\(outcome.token), transition=\(transition.token), completed=\(completedCards), selected=\(selectedCards), attempts=\(generatedAttempts)"
}

static func progressiveAttemptRejectedMessage(reason: String) -> String {
"progressive attempt rejected | reason=\(reason)"
}

static func progressiveSessionCompletedMessage(cards: Int, attempts: Int) -> String {
"progressive session completed | cards=\(cards), attempts=\(attempts)"
}

static func progressiveSessionCreatedMessage(cards: Int) -> String {
"progressive session created | cards=\(cards)"
}

static func progressiveSessionRejectedMessage(reason: String) -> String {
"progressive session rejected | reason=\(reason)"
}

static func formatted(_ message: String) -> String {
"\(emoji) \(message)"
}
Expand All @@ -29,6 +88,26 @@ enum FlashcardLogging {
}
}

extension ProgressiveFlashcardEvaluation.Outcome {
var token: String {
switch self {
case .correct: "correct"
case .expired: "expired"
case .incorrect: "incorrect"
}
}
}

extension ProgressiveFlashcardEvaluation.Transition {
var token: String {
switch self {
case .completed: "completed"
case .promoted: "promoted"
case .retained: "retained"
}
}
}

extension ThreeChoiceEvaluation.Outcome {
var token: String {
switch self {
Expand Down
16 changes: 16 additions & 0 deletions Sources/FlashcardKit/ProgressiveFlashcardAttempt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
public import Foundation

/// One session-scoped opportunity to exercise a card's current stage.
public struct ProgressiveFlashcardAttempt: Identifiable, Codable, Sendable, Hashable {
/// Monotonic zero-based identity scoped to the containing session.
public let id: Int

/// Stable host-authored card identity.
public let cardID: UUID

/// Zero-based position of the stage in the card's ordered stages.
public let stageIndex: Int

/// Exact stage currently being exercised.
public let stage: FlashcardStage
}
43 changes: 43 additions & 0 deletions Sources/FlashcardKit/ProgressiveFlashcardEvaluation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
public import Foundation

/// Progression result for one accepted host-evaluated attempt.
public struct ProgressiveFlashcardEvaluation: Codable, Sendable, Hashable {
/// Response-mode-agnostic outcomes accepted by the progression engine.
public enum Outcome: Codable, Sendable, Hashable {
/// The host determined that the response was correct.
case correct

/// The host determined that the response window expired.
case expired

/// The host determined that the response was incorrect.
case incorrect
}

/// Queue and stage transition produced by an accepted outcome.
public enum Transition: Codable, Sendable, Hashable {
/// A correct response completed the card's final stage.
case completed

/// The card advanced and was requeued at the supplied next stage.
case promoted(to: FlashcardStageID)

/// The card remained on the same stage and was requeued.
case retained
}

/// Identity of the evaluated attempt.
public let attemptID: Int

/// Stable identity of the evaluated card.
public let cardID: UUID

/// Stable identity of the evaluated stage.
public let stageID: FlashcardStageID

/// Host-decided outcome consumed by the progression engine.
public let outcome: Outcome

/// Resulting card progression and queue transition.
public let transition: Transition
}
13 changes: 13 additions & 0 deletions Sources/FlashcardKit/ProgressiveFlashcardProgress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

/// Aggregate progress for a progressive flashcard session.
public struct ProgressiveFlashcardProgress: Codable, Sendable, Hashable {
/// Distinct selected cards that completed every stage.
public let completedCards: Int

/// Attempts issued so far, including the currently outstanding attempt.
public let generatedAttempts: Int

/// Distinct cards selected when the session was created.
public let selectedCards: Int
}
161 changes: 161 additions & 0 deletions Sources/FlashcardKit/ProgressiveFlashcardSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Foundation

/// A deterministic session that advances cards through ordered recall stages.
public struct ProgressiveFlashcardSession: Sendable {
private struct PendingCard: Sendable {
let card: Flashcard
var stageIndex: Int
}

private var completedCardCount = 0
private var currentAttemptID: Int?
private var nextAttemptID: Int
private var queue: [PendingCard]
private let selectedCardCount: Int

/// The attempt awaiting a host-decided outcome, or `nil` after completion.
public var currentAttempt: ProgressiveFlashcardAttempt? {
guard let currentAttemptID, let pending = queue.first else {
return nil
}
return ProgressiveFlashcardAttempt(
id: currentAttemptID,
cardID: pending.card.id,
stageIndex: pending.stageIndex,
stage: pending.card.stages[pending.stageIndex]
)
}

/// Current card completion and generated-attempt counts.
public var progress: ProgressiveFlashcardProgress {
ProgressiveFlashcardProgress(
completedCards: completedCardCount,
generatedAttempts: nextAttemptID,
selectedCards: selectedCardCount
)
}

/// Whether every selected card completed its final stage.
public var isComplete: Bool {
completedCardCount == selectedCardCount
}

/// Creates a deterministic progressive session from host-owned cards.
///
/// - Parameters:
/// - cards: Cards eligible for deterministic selection.
/// - configuration: Seed and distinct-card selection count.
/// - Throws: ``ProgressiveFlashcardSessionError`` when the source cards or configuration are invalid.
public init(
cards: [Flashcard],
configuration: ProgressiveFlashcardSessionConfiguration
) throws {
guard configuration.cardCount > 0 else {
FlashcardLogging.progressiveSessionRejected(reason: "invalid-card-count")
throw ProgressiveFlashcardSessionError.invalidCardCount(configuration.cardCount)
}

var cardIDs = Set<UUID>()
for card in cards where !cardIDs.insert(card.id).inserted {
FlashcardLogging.progressiveSessionRejected(reason: "duplicate-card-id")
throw ProgressiveFlashcardSessionError.duplicateCardID(card.id)
}

guard configuration.cardCount <= cards.count else {
FlashcardLogging.progressiveSessionRejected(reason: "card-count-exceeds-cards")
throw ProgressiveFlashcardSessionError.cardCountExceedsAvailableCards(
requested: configuration.cardCount,
available: cards.count
)
}

var orderedCards = cards.sorted { $0.id.uuidString < $1.id.uuidString }
var random = DeterministicRandom(seed: configuration.seed)
random.shuffle(&orderedCards)
let selectedCards = orderedCards.prefix(configuration.cardCount)

queue = selectedCards.map { PendingCard(card: $0, stageIndex: 0) }
currentAttemptID = 0
nextAttemptID = 1
selectedCardCount = configuration.cardCount
FlashcardLogging.progressiveSessionCreated(cards: selectedCardCount)
}

/// Applies one host-decided outcome to the exact current attempt.
///
/// - Parameters:
/// - outcome: Correct, incorrect, or expired result decided by the host's evaluation mechanism.
/// - attemptID: Identity of the attempt that produced the outcome.
/// - Returns: The accepted outcome and resulting stage transition.
/// - Throws: ``ProgressiveFlashcardSessionError`` when the session is complete or the attempt identity is invalid or stale. Rejected submissions do not mutate the session.
public mutating func submit(
_ outcome: ProgressiveFlashcardEvaluation.Outcome,
forAttemptID attemptID: Int
) throws -> ProgressiveFlashcardEvaluation {
guard let currentAttemptID else {
FlashcardLogging.progressiveAttemptRejected(reason: "session-complete")
throw ProgressiveFlashcardSessionError.sessionComplete
}
guard attemptID >= 0, attemptID < nextAttemptID else {
FlashcardLogging.progressiveAttemptRejected(reason: "invalid-attempt")
throw ProgressiveFlashcardSessionError.invalidAttempt(attemptID)
}
guard attemptID == currentAttemptID else {
FlashcardLogging.progressiveAttemptRejected(reason: "stale-attempt")
throw ProgressiveFlashcardSessionError.staleAttempt(
expected: currentAttemptID,
received: attemptID
)
}

var pending = queue.removeFirst()
let evaluatedStage = pending.card.stages[pending.stageIndex]
let transition: ProgressiveFlashcardEvaluation.Transition
switch outcome {
case .correct where pending.card.stages.indices.contains(pending.stageIndex + 1):
pending.stageIndex += 1
queue.append(pending)
transition = .promoted(to: pending.card.stages[pending.stageIndex].id)
case .correct:
completedCardCount += 1
transition = .completed
case .expired, .incorrect:
queue.append(pending)
transition = .retained
}

issueNextAttempt()
let evaluation = ProgressiveFlashcardEvaluation(
attemptID: attemptID,
cardID: pending.card.id,
stageID: evaluatedStage.id,
outcome: outcome,
transition: transition
)
FlashcardLogging.progressiveAttemptAccepted(
outcome: outcome,
transition: transition,
completedCards: completedCardCount,
selectedCards: selectedCardCount,
generatedAttempts: nextAttemptID
)
if isComplete {
FlashcardLogging.progressiveSessionCompleted(
cards: selectedCardCount,
attempts: nextAttemptID
)
}
return evaluation
}

// MARK: - Private

private mutating func issueNextAttempt() {
guard !queue.isEmpty else {
currentAttemptID = nil
return
}
currentAttemptID = nextAttemptID
nextAttemptID += 1
}
}
Loading