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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,6 @@ Replace these examples with exact repository paths:

## Local specialization

- FlashcardKit's canonical logging emoji is 馃儚. Package-owned session logs use subsystem `com.thatfactory.flashcardkit` and never include prompts, answers, asset references, or card identifiers.
- Do not depend on LexiconKit; consumers map between the two domains.
- Keep session mechanics deterministic for identical cards, configuration, and seed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ All notable changes to FlashcardKit are documented here.

### Added

- Added deterministic finite three-choice sessions with seeded ordering, distinct distractors, explicit expiry, answer evaluation, progress, typed validation, and stale-round protection.
- Added validated, persistence-friendly `Flashcard` and `FlashcardContent` public models for text and opaque host-owned asset references.
- Bootstrapped the Swift package, DocC catalog, CI/CD workflows, shared AgentGuidelines integration, and repository policy.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,21 @@ let card = Flashcard(
prompt: try FlashcardContent(text: "der Hund"),
answer: try FlashcardContent(text: "dog")
)

var session = try ThreeChoiceSession(
cards: cards,
configuration: ThreeChoiceSessionConfiguration(seed: 42, roundCount: 5)
)

if let round = session.currentRound {
let evaluation = try session.submit(
.selection(choiceID: round.choices[0].id),
forRoundID: round.id
)
}
```

Presentation, timers, persistence frameworks, image resolution, vocabulary acquisition, and spaced repetition remain outside the package boundary.
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.

## Documentation

Expand Down
26 changes: 26 additions & 0 deletions Sources/FlashcardKit/DeterministicRandom.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
struct DeterministicRandom: Sendable {
private var state: UInt64

init(seed: UInt64) {
state = seed
}

mutating func shuffle<Element>(_ values: inout [Element]) {
guard values.count > 1 else { return }
for index in stride(from: values.count - 1, through: 1, by: -1) {
values.swapAt(index, randomIndex(upperBound: index + 1))
}
}

private mutating func randomIndex(upperBound: Int) -> Int {
Int(next() % UInt64(upperBound))
}

private mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
return value ^ (value >> 31)
}
}
15 changes: 15 additions & 0 deletions Sources/FlashcardKit/FlashcardKit.docc/FlashcardKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,18 @@ let card = Flashcard(
```

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.

```swift
var session = try ThreeChoiceSession(
cards: cards,
configuration: ThreeChoiceSessionConfiguration(seed: 42)
)

if let round = session.currentRound {
let evaluation = try session.submit(.expired, forRoundID: round.id)
}
```

FlashcardKit uses a stable internal random algorithm. Identical card sets, configuration, and seed reproduce shuffled plans independently of the host collection's input order; source ordering intentionally preserves input order.
40 changes: 40 additions & 0 deletions Sources/FlashcardKit/FlashcardLogging.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import AppLogger

enum FlashcardLogging {
static let emoji = "馃儚"
static let subsystem = "com.thatfactory.flashcardkit"

static func sessionCreated(rounds: Int) {
log("session created | rounds=\(rounds)")
}

static func sessionRejected(reason: String) {
log(level: .error, "session rejected | reason=\(reason)")
}

static func responseAccepted(outcome: ThreeChoiceEvaluation.Outcome, completed: Int, total: Int) {
log("response accepted | outcome=\(outcome.token), completed=\(completed), total=\(total)")
}

static func responseRejected(reason: String) {
log(level: .error, "response rejected | reason=\(reason)")
}

static func formatted(_ message: String) -> String {
"\(emoji) \(message)"
}

private static func log(level: AppLogLevel = .debug, _ message: String) {
AppLogger(subsystem: subsystem, category: "session").log(level: level, formatted(message))
}
}

extension ThreeChoiceEvaluation.Outcome {
var token: String {
switch self {
case .correct: "correct"
case .incorrect: "incorrect"
case .expired: "expired"
}
}
}
134 changes: 134 additions & 0 deletions Sources/FlashcardKit/ThreeChoiceModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import Foundation

/// Controls how prompt cards are ordered when a session plan is created.
public enum ThreeChoiceRoundOrdering: Codable, Sendable, Hashable {
/// Preserve the order supplied by the host.
case source

/// Canonicalize cards by identity, then shuffle them using the configured seed.
case shuffled
}

/// Configuration used to create a deterministic three-choice session.
public struct ThreeChoiceSessionConfiguration: Codable, Sendable, Hashable {
/// Seed used for prompt, distractor, and choice ordering.
public let seed: UInt64

/// Number of rounds to play, or `nil` to use every card once.
public let roundCount: Int?

/// Prompt ordering policy.
public let ordering: ThreeChoiceRoundOrdering

/// Creates a session configuration.
///
/// - Parameters:
/// - seed: Seed used by FlashcardKit's stable deterministic random algorithm.
/// - roundCount: Positive round count, or `nil` to use every card once.
/// - ordering: Prompt ordering policy.
public init(
seed: UInt64,
roundCount: Int? = nil,
ordering: ThreeChoiceRoundOrdering = .shuffled
) {
self.seed = seed
self.roundCount = roundCount
self.ordering = ordering
}
}

/// One visible answer choice in a three-choice round.
public struct ThreeChoice: Identifiable, Codable, Sendable, Hashable {
/// Deterministic identifier scoped to the containing round.
public let id: Int

/// Content presented as the possible answer.
public let content: FlashcardContent
}

/// A presentation-ready prompt and its three possible answers.
public struct ThreeChoiceRound: Identifiable, Codable, Sendable, Hashable {
/// Deterministic zero-based position in the session.
public let id: Int

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

/// Content presented for recall.
public let prompt: FlashcardContent

/// Three distinct possible answers in deterministic order.
public let choices: [ThreeChoice]
}

/// A host-submitted response to the currently visible round.
public enum ThreeChoiceResponse: Sendable, Hashable {
/// The player selected a visible choice.
case selection(choiceID: Int)

/// The host-owned answer window expired.
case expired
}

/// The deterministic outcome of one accepted response.
public struct ThreeChoiceEvaluation: Codable, Sendable, Hashable {
/// Possible outcomes for an accepted response.
public enum Outcome: Codable, Sendable, Hashable {
/// The selected choice matched the answer.
case correct

/// The selected choice did not match the answer.
case incorrect

/// The host reported that its answer window expired.
case expired
}

/// Identity of the evaluated round.
public let roundID: Int

/// Result of the submitted response.
public let outcome: Outcome

/// Selected choice identity, or `nil` for expiry.
public let selectedChoiceID: Int?

/// Choice identity containing the correct answer.
public let correctChoiceID: Int
}

/// Finite session progress after zero or more accepted responses.
public struct ThreeChoiceProgress: Codable, Sendable, Hashable {
/// Number of rounds already evaluated.
public let completedRounds: Int

/// Total number of planned rounds.
public let totalRounds: Int
}

/// Validation and submission failures from a three-choice session.
public enum ThreeChoiceSessionError: Error, Sendable, Equatable {
/// More than one card used the same stable identity.
case duplicateCardID(UUID)

/// Fewer than three cards were supplied.
case insufficientCards(minimum: Int, actual: Int)

/// The supplied cards contain fewer than three distinct visible answers.
case insufficientDistinctAnswers(minimum: Int, actual: Int)

/// The requested round count was not positive.
case invalidRoundCount(Int)

/// The requested round count exceeded the supplied card count.
case roundCountExceedsAvailableCards(requested: Int, available: Int)

/// A response was submitted after the final round.
case sessionComplete

/// A response targeted a round other than the current round.
case staleRound(expected: Int, received: Int)

/// The selected choice does not belong to the current round.
case invalidChoice(Int)
}
Loading