summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/card.py13
-rw-r--r--src/flashcard_cli.py58
-rw-r--r--src/parser.py108
-rw-r--r--src/parser_unittest.py72
-rw-r--r--src/scheduler.py61
-rw-r--r--src/scheduler_brutal.py78
-rw-r--r--src/scheduler_brutal_unittest.py87
-rw-r--r--src/session.py50
-rw-r--r--src/session_integrationtest.py46
-rw-r--r--src/state_json.py24
-rw-r--r--src/state_json_unittest.py13
11 files changed, 0 insertions, 610 deletions
diff --git a/src/card.py b/src/card.py
deleted file mode 100644
index c2243a6..0000000
--- a/src/card.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""
-Defines a struct representing a single card. The struct takes the form:
-
-(front, back)
-"""
-
-from collections import namedtuple
-from hashlib import md5
-
-Card = namedtuple('Card', ['front', 'back'])
-
-def getId(card: Card) -> str:
- return md5((card.front + card.back).encode("utf-8")).hexdigest()
diff --git a/src/flashcard_cli.py b/src/flashcard_cli.py
deleted file mode 100644
index be05be8..0000000
--- a/src/flashcard_cli.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import click
-from random import shuffle
-
-from scheduler import SCHEDULERS
-from session import Session
-from card import Card
-
-@click.group()
-def cli():
- pass
-
-def displayCard(card: Card, index: int, random_flip: bool) -> None:
- click.echo(click.style(f"{index + 1} ===========================================================", fg="blue"))
-
- faces = [card.front, card.back]
-
- if random_flip:
- shuffle(faces)
-
- click.echo(click.style(faces.pop(0), fg="yellow"))
- input()
- click.echo(faces.pop(0))
-
-
-@cli.command()
-@click.argument("state_file", nargs=1, type=click.Path())
-@click.argument("card_files", nargs=-1, type=click.Path(exists=True))
-@click.option("--scheduler", "scheduler_name", default="brutal", type=click.Choice(SCHEDULERS, case_sensitive=False), help="Name of desired scheduler")
-@click.option("--count", default=20, type=int, help="Number of cards to show during the session")
-@click.option("--random_flip", is_flag=True, help="Prompt with card front or back randomly instead of always front")
-def practice(state_file, card_files, scheduler_name, count, random_flip):
- """
- Run a practice session with the specified scheduler, using the provided state and card files.
- """
- session = Session(scheduler_name, card_files, state_file)
-
- for i, card in enumerate(session.practice(count)):
- displayCard(card, i, random_flip)
-
-@cli.command()
-@click.argument("state_file", nargs=1, type=click.Path())
-@click.argument("card_files", nargs=-1, type=click.Path(exists=True))
-@click.option("--scheduler", "scheduler_name", default="brutal", type=click.Choice(SCHEDULERS, case_sensitive=False), help="Name of desired scheduler")
-@click.option("--count", default=20, type=int, help="Number of cards to show during the session")
-@click.option("--random_flip", is_flag=True, help="Prompt with card front or back randomly instead of always front")
-def test(state_file, card_files, scheduler_name, count, random_flip):
- """
- Run a test session with the specified scheduler, using the provided state and card files.
- """
- session = Session(scheduler_name, card_files, state_file)
-
- for i, (card, correct) in enumerate(session.test(count)):
- displayCard(card, i, random_flip)
- correct(click.confirm(click.style("Correct?", bold=True)))
-
-if __name__ == '__main__':
- cli()
-
diff --git a/src/parser.py b/src/parser.py
deleted file mode 100644
index 11bb0c6..0000000
--- a/src/parser.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""
-Load .fcard files into dictionaries.
-
-The parser expects .fcard files in the following format:
-
-FRONT
-This is the front of the first card.
-
-BACK
-This is the back of the first card.
-
-FRONT
-This is another card.
-
-Multiple lines on the front are allowed.
-
-BACK
-Multiple lines on the back?
-
-Also allowed.
-
-FRONT
-...
-
-The cards are represented in dictionary entries of the form:
-
-id: card.Card
-"""
-
-from pathlib import Path
-from enum import Enum
-from typing import TextIO, Iterator
-from card import Card, getId
-
-def _getCard(front_lines: list[str], back_lines: list[str]) -> tuple[str, Card]:
- front_text = "".join(front_lines).strip()
- back_text = "".join(back_lines).strip()
- card = Card(front_text, back_text)
- id = getId(card)
- return id, card
-
-def _getCards(f: TextIO) -> Iterator[tuple[str, Card]]:
- class State(Enum):
- PARSE_FRONT = 1,
- PARSE_BACK = 2
-
- state = None
- front_lines = []
- back_lines = []
-
- for i, line in enumerate(f):
- match line.strip():
- case "FRONT":
- # Edge case: FRONT twice in a row
- if state == State.PARSE_FRONT:
- raise Exception(f"Unexpected 'FRONT': {f}:{i}")
-
- # Next card is starting, wrap up current one
- if state == State.PARSE_BACK:
- yield _getCard(front_lines, back_lines)
- front_lines.clear()
- back_lines.clear()
-
- state = State.PARSE_FRONT
-
- case "BACK":
- # Edge case: BACK without FRONT before it
- if state != State.PARSE_FRONT:
- raise Exception(f"Unexpected 'BACK': {f}:{i}")
-
- state = State.PARSE_BACK
-
- case _:
- match state:
- case State.PARSE_FRONT:
- front_lines += line
- case State.PARSE_BACK:
- back_lines += line
- # Edge case: file does not start with FRONT, flush preamble
- case _:
- continue
-
- # Edge case: file did not end with contents of BACK
- if state == State.PARSE_FRONT:
- raise Exception(f"Unexpected end of file")
-
- # Edge case: file was empty
- if state is None:
- return
-
- yield _getCard(front_lines, back_lines)
-
-def parseFile(path: str) -> dict[str, Card]:
- """
- Parse a .fcard file and return a dictionary of Card instances indexed by ID.
- """
- with open(path, "r") as f:
- return {id : card for id, card in _getCards(f)}
-
-def parseFiles(paths: list[str]) -> dict[str, Card]:
- """
- Parse a list of .fcard files and return a dictionary of Card instances indexed by ID.
- """
- cards = {}
- for p in paths:
- cards |= parseFile(p)
- return cards
-
diff --git a/src/parser_unittest.py b/src/parser_unittest.py
deleted file mode 100644
index 8d0d600..0000000
--- a/src/parser_unittest.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import pytest
-import parser
-from pathlib import Path
-
-# Happy path
-def test_validFile(tmp_path):
- file_contents = """
-
-FRONT
-
-
-Foo
-
-Bar
-
-BACK
-
-
-Fizz
-
-Buzz
-
-
-
-FRONT
-
-Another card
-
-BACK
-
-Another back
-
-
- """
- expected = {
- ("Foo\n\nBar", "Fizz\n\nBuzz"),
- ("Another card", "Another back")
- }
-
- path = tmp_path / "valid_file.fcard"
- with open(path, "w") as f:
- f.write(file_contents)
-
- cards = parser.parseFile(path)
-
- assert expected == set(cards.values())
-
-# Edge cases
-def test_emptyFile(tmp_path):
- path = tmp_path / "empty.fcard"
- with open(path, "w") as f:
- f.write("")
-
- cards = parser.parseFile(path)
- assert cards == {}
-
-def checkException(tmp_path, file_contents):
- path = tmp_path / "invalid_file.fcard"
- with open(path, "w") as f:
- f.write(file_contents)
-
- with pytest.raises(Exception):
- cards = parser.parseFile(path)
-
-def test_doesNotStartWithFront(tmp_path):
- checkException(tmp_path, "BACK\noops")
-
-def test_frontTwiceInARow(tmp_path):
- checkException(tmp_path, "FRONT\noops\nFRONT\nbad")
-
-def test_doesNotEndWithBack(tmp_path):
- checkException(tmp_path, "FRONT\ntest\nBACK\ntest\nFRONT\noops")
diff --git a/src/scheduler.py b/src/scheduler.py
deleted file mode 100644
index e1c783b..0000000
--- a/src/scheduler.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from typing import Protocol
-from abc import abstractmethod
-from card import Card
-
-class Scheduler(Protocol):
- """
- Schedulers must implement this interface to be usable in a session.
- """
- @abstractmethod
- def __init__(self, cards: dict[str, Card], state: dict):
- """
- Create a new instance of the scheduler from a dictionary of
- Cards indexed by ID and a scheduler-specific state as a dict.
- """
- raise NotImplementedError
-
- @abstractmethod
- def practice(self, size: int) -> list[str]:
- """
- Return a list of card IDs of the requested size, if possible.
- This list is intended for practice.
- """
- raise NotImplementedError
-
- @abstractmethod
- def test(self, size: int) -> list[str]:
- """
- Return a list of card IDs of the requested size, if possible.
- This list is intended to test the player's knowledge.
- """
- raise NotImplementedError
-
- @abstractmethod
- def update(self, results: dict[str, int]) -> None:
- """
- Takes a dictionary of card IDs and integers, where the integer
- is 0 if the player failed to guess the other side of the card,
- of 1 if the player succeeded.
- """
- raise NotImplementedError
-
- @abstractmethod
- def getState(self) -> dict:
- """
- Return the scheduler's state for storage.
- """
- raise NotImplementedError
-
-SCHEDULERS = ["brutal"]
-
-def getSchedulerClass(name: str) -> Scheduler:
- """
- Returns the class object for the requested scheduler, if one exists.
- """
- match name:
- case "brutal":
- from scheduler_brutal import SchedulerBrutal
- return SchedulerBrutal
- case _:
- raise Exception(f"Unknown scheduler: {name}")
-
diff --git a/src/scheduler_brutal.py b/src/scheduler_brutal.py
deleted file mode 100644
index f0bfe99..0000000
--- a/src/scheduler_brutal.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from scheduler import Scheduler
-from card import Card
-from random import shuffle
-
-HISTORY_DEPTH = 8
-
-class SchedulerBrutal(Scheduler):
- """
- The brutal scheduler tracks how well the player has consolidated each card
- and also how often the card has been shown.
-
- Using this information, it prioritizes cards that have been shown less
- frequently and recently, which means the player will often see totally new
- cards in test sessions.
- """
- def __init__(self, cards: dict[str, Card], state: dict):
- self._cards = cards
- self._state = {}
-
- # Synchronise state with current card collection
- for id, card in self._cards.items():
- history = state.get(id, [None] * HISTORY_DEPTH)
-
- # Adjust history if depth has changed
- if len(history) > HISTORY_DEPTH:
- history = history[-HISTORY_DEPTH:]
- elif len(history) < HISTORY_DEPTH:
- history = ([None] * (HISTORY_DEPTH - len(history))) + history
-
- self._state[id] = history
-
- def practice(self, size: int) -> list[str]:
- return self._schedule(size)
-
- def test(self, size: int) -> list[str]:
- return self._schedule(size)
-
- def update(self, results: dict[str, int]) -> None:
- # Add card result to sliding window, or None if card was not shown
- self._state = {id: history[1:] + [results.get(id, None)]
- for id, history in self._state.items()}
-
- def getState(self) -> dict:
- return self._state
-
- @staticmethod
- def _consolidationIndex(history: list, weights: range) -> float:
- """
- Consolidation index is a measure of how well the player has guessed the card recently
- """
- relevant_history = [(h, w) for h, w in zip(history, weights) if h is not None]
- weighted_history = sum([h * w for h, w in relevant_history])
- total_weights = sum([w for h, w in relevant_history])
- return weighted_history / total_weights if total_weights > 0 else 0.0
-
- @staticmethod
- def _exposureIndex(history: list) -> float:
- """
- Exposure index is a measure of how much and how recently a card has been shown
- """
- return sum([i + 1 for i, h in enumerate(history) if h is not None])
-
- def _schedule(self, size: int) -> list[str]:
- weights = range(10, 10 + HISTORY_DEPTH)
- cards = [id for id, card in self._cards.items()]
-
- # First sort by consolidation index
- cards.sort(key=lambda id: SchedulerBrutal._consolidationIndex(self._state[id], weights))
-
- # Next sort by exposure index
- cards.sort(key=lambda id: SchedulerBrutal._exposureIndex(self._state[id]))
-
- # Return least exposed and least consolidated cards, shuffled
- cards = cards[0:size]
-
- shuffle(cards)
-
- return cards
diff --git a/src/scheduler_brutal_unittest.py b/src/scheduler_brutal_unittest.py
deleted file mode 100644
index 8dc72fe..0000000
--- a/src/scheduler_brutal_unittest.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import pytest
-import scheduler_brutal
-from scheduler_brutal import SchedulerBrutal as UUT
-from card import Card
-
-# Force HISTORY_DEPTH to simplify testing
-scheduler_brutal.HISTORY_DEPTH = 3
-
-#--------------------------------------------------------------------------
-# Scheduling behaviour
-#--------------------------------------------------------------------------
-def test_scheduling():
- cards = {str(id): Card("", "") for id in range(0, 10)}
- state = {
- "0": [1, 1, 1],
- "1": [0, 0, 0],
- "2": [0, 0, 1],
- "3": [1, 0, 0],
-
- "4": [None, None, 1 ],
- "5": [None, 1, None],
- "6": [1, None, None],
- "7": [None, None, 0 ],
- "8": [0, 0, None],
- "9": [None, None, None],
- }
-
- expected_priority = ["9", "6", "5", "7", "8", "4", "1", "3", "2", "0"]
-
- uut = UUT(cards, state)
-
- for i in range(0, len(expected_priority)):
- assert set(uut.practice(i + 1)) == set(expected_priority[0:i + 1])
-
-#--------------------------------------------------------------------------
-# State update
-#--------------------------------------------------------------------------
-def test_stateUpdate():
- cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("c", "d")}
- state = {"0": [1, 0, 1], "1": [1, 0, 0], "2": [0, 0, 1]}
-
- uut = UUT(cards, state)
-
- # Unknown IDs in the result are silently ignored
- result = {"0": 1, "1": 0, "3": 0}
- expected_state = {"0": [0, 1, 1], "1": [0, 0, 0], "2": [0, 1, None]}
-
- uut.update(result)
-
- assert uut.getState() == expected_state
-
-#--------------------------------------------------------------------------
-# State corrections
-#--------------------------------------------------------------------------
-def test_stateWhenCardsChanged():
- cards = {"0": Card("f", "b"), "1": Card("a", "b")}
-
- initial_state = {"0": [1, 0, 1], "2": [0, 0, 0]}
- expected_state = {"0": [1, 0, 1], "1": [None, None, None]}
-
- uut = UUT(cards, initial_state)
-
- assert uut.getState() == expected_state
-
-def test_stateWhenHistoryDepthIncreased():
- scheduler_brutal.HISTORY_DEPTH = 5
-
- cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("new", "new")}
-
- initial_state = {"0": [1, 0, 1], "1": [0, 0, 0]}
- expected_state = {"0": [None, None, 1, 0, 1], "1": [None, None, 0, 0, 0], "2": [None] * 5}
-
- uut = UUT(cards, initial_state)
-
- assert uut.getState() == expected_state
-
-def test_stateWhenHistoryDepthDecreased():
- scheduler_brutal.HISTORY_DEPTH = 1
-
- cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("new", "new")}
-
- initial_state = {"0": [1, 0, 0], "1": [0, 0, 1]}
- expected_state = {"0": [0], "1": [1], "2": [None]}
-
- uut = UUT(cards, initial_state)
-
- assert uut.getState() == expected_state
diff --git a/src/session.py b/src/session.py
deleted file mode 100644
index f30160f..0000000
--- a/src/session.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from typing import Iterator, Callable
-
-from card import Card
-from scheduler import getSchedulerClass
-from parser import parseFiles
-from state_json import load, save
-
-class Session:
- """
- Represents a play session. During a session, multiple practice and test runs
- can be made with the same scheduler.
- """
- def __init__(self, scheduler_name: str, card_files: list[str], state_file: str):
- self._cards = parseFiles(card_files)
- self._state_file = state_file
- self._scheduler = getSchedulerClass(scheduler_name)(self._cards, load(state_file))
-
- def practice(self, size: int) -> Iterator[Card]:
- """
- Yields cards for a practice run of the requested size.
-
- Practice runs do not affect the scheduler state.
- """
- ids = self._scheduler.practice(size)
- for id in ids:
- yield self._cards[id]
-
- def test(self, size: int) -> Iterator[tuple[Card, Callable]]:
- """
- Yields cards for a test run of the requested size.
-
- A function is yielded with each card that takes single boolean argument.
- The UI is expected to call the function for each card to indicate whether
- the user correctly guessed the card (True) or not (False).
-
- Multiple subsequent calls to the same function overwrite past results.
-
- When the test run is done, the scheduler state is updated with the
- collected results
- """
- ids = self._scheduler.practice(size)
- results = {}
-
- for id in ids:
- def result(correct: bool) -> None:
- results[id] = int(correct)
- yield self._cards[id], result
-
- self._scheduler.update(results)
- save(self._state_file, self._scheduler.getState())
diff --git a/src/session_integrationtest.py b/src/session_integrationtest.py
deleted file mode 100644
index 2c5d608..0000000
--- a/src/session_integrationtest.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import pytest
-import json
-from session import Session
-
-@pytest.fixture
-def cardFiles(tmp_path):
- card_files = [
- tmp_path / "test1.fcard",
- tmp_path / "test2.fcard",
- ]
-
- for i, c in enumerate(card_files):
- with open(c, "w") as f:
- for j in range(0, 3):
- f.write(f"FRONT\nFile {i}, card {j} front\nBACK\nback\n")
-
- return card_files
-
-@pytest.fixture
-def stateFile(tmp_path):
- return tmp_path / "state.json"
-
-def test_practiceSession(cardFiles, stateFile):
- session = Session("brutal", cardFiles, stateFile)
-
- c = 0
- for card in session.practice(5):
- c += 1
-
- assert c == 5
-
-def test_testSession(cardFiles, stateFile):
- session = Session("brutal", cardFiles, stateFile)
-
- c = 0
- for i, (card, correct) in enumerate(session.test(5)):
- c += 1
- correct(i % 2 == 0)
-
- assert c == 5
-
- with open(stateFile, "r") as f:
- state = json.load(f)
- latest_scores = [history[-1] for id, history in state.items()]
- assert latest_scores.count(0) == 2
- assert latest_scores.count(1) == 3
diff --git a/src/state_json.py b/src/state_json.py
deleted file mode 100644
index a0b487e..0000000
--- a/src/state_json.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""
-Helper functions to store scheduler state as json
-"""
-
-import json
-from pathlib import Path
-
-def save(file: str, state: dict) -> None:
- """
- Dump the specified state dictionary in JSON format
- """
- with open(file, "w") as f:
- json.dump(state, f)
-
-def load(file: str) -> dict:
- """
- Load the state from the specified file and return
- an empty dictionary silently if the file doesn't exist.
- """
- try:
- with open(file, "r") as f:
- return json.load(f)
- except:
- return {}
diff --git a/src/state_json_unittest.py b/src/state_json_unittest.py
deleted file mode 100644
index e784f58..0000000
--- a/src/state_json_unittest.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import pytest
-import state_json
-
-def test_saveAndLoad(tmp_path):
- file = tmp_path / "test.json"
- state = {"key": [10, 20, None], "another_key": "value"}
-
- state_json.save(file, state)
-
- assert state_json.load(file) == state
-
-def test_missingFile(tmp_path):
- assert state_json.load(tmp_path / "missing.json") == {}