I thought this was an interesting problem, so I wrote my own version in Python. It’s pretty clunky and I haven’t checked it yet (in hindsight, it probably wasn’t the best idea to do this in one sitting without planning anything), but I had fun and learned a bit.
import math
import matplotlib.pyplot as plt
import os
import pickle
import random
import requests
class Simulation(object):
class SimulationUnit(object): # It would probably be more elegant if this
# were a subclass of Subject, but I want to
# be explicit that this is only for the
# simulation, while Subjects are more general
def __init__(
self,
subject,
f_correct=lambda: True # Accuracy function
):
self.subject = subject
self.f_correct = f_correct
self.unlocked = False
self.srs_level = 0
self.next_review = math.inf
def simulate_review(self, hour):
# See https://knowledge.wanikani.com/wanikani/srs-stages/ for more
# information about the penalty factor and incorrect adjustment ct.
srs_penalty_factor = 2 if self.srs_level >= 5 else 1
if self.f_correct():
self.srs_level += 1
else:
incorrect_times = 1
while (not self.f_correct()): # I am not completely sure how
# this works, but it seems like
# it's based on the number of
# reviews you miss in a single
# session of review
incorrect_times += 1
incorrect_adjustment_ct = math.ceil(incorrect_times / 2)
self.srs_level -= incorrect_adjustment_ct * srs_penalty_factor
self.srs_level = max(1, self.srs_level) # >= Apprentice 1
if self.srs_level == 9:
self.next_review = math.inf
else:
self.next_review = (
hour + self.subject.srs_hours[self.srs_level - 1]
)
def simulate_lesson(self, hour):
self.srs_level = 1
self.next_review = hour + self.subject.srs_hours[0]
def __init__(
self,
subjects,
max_apprentice=None,
max_lessons_day=None,
max_reviews_day=None,
lesson_batch=5, # Batch size for lessons; 5 by default
lesson_times=range(24), # Hours at which lessons are done (24-hour)
review_times=range(24),
hours_to_simulate=24 * 365
):
self.units = dict(
[(subject.identity, self.SimulationUnit(subject))
for subject in subjects.values()]
)
self.max_apprentice = max_apprentice
self.max_lessons_day = max_lessons_day
self.max_reviews_day = max_reviews_day
self.lesson_batch = lesson_batch
self.lesson_times = lesson_times
self.review_times = review_times
self.hours_to_simulate = hours_to_simulate
self.current_level = 1
self.update_unlocks()
def update_unlocks(self):
for unit in self.units.values():
if unit.unlocked or unit.subject.level > self.current_level:
continue # Don't bother with unlocked items or higher levels
# Disqualify (don't unlock) items for which the prerequsities aren't
# completed (at least a level of Guru 1)
meets_prereqs = True
if any([self.units[prereq].srs_level < 5
for prereq in unit.subject.i_depend_on]):
meets_prereqs = False
unit.unlocked = meets_prereqs
def evaluate_level_up(self):
kanji_in_level = [num for num, unit in self.units.items()
if (
unit.subject.level == self.current_level
and unit.subject.classification == "kanji"
)
]
kanji_passed_in_level = [num for num in kanji_in_level
if self.units[num].srs_level >= 5]
if (
len(kanji_passed_in_level) / len(kanji_in_level) >= 0.9
and self.current_level <= 59
):
self.current_level += 1
self.update_unlocks()
print(f"Level up: {self.current_level}")
return True
return False
def get_number_apprentice(self):
return len([unit for unit in self.units.values()
if unit.srs_level >= 1 and unit.srs_level <= 4])
def fetch_lesson_unit_nums(self):
# Sorting ensures lessons are presented in the appropriate order
return sorted(
[num for num, unit in self.units.items()
if unit.unlocked and unit.srs_level == 0],
key=lambda num: (
self.units[num].subject.level, # Sort first by level
self.units[num].subject.lesson_position # and second by index
# within the level
)
)
def fetch_review_unit_nums(self, hour):
review_units = [num for num, unit in self.units.items()
if unit.next_review <= hour]
random.shuffle(review_units) # By default, reviews shouldn't appear in
# a particular order
return review_units
def simulate(self):
hour = 0
lessons_today, reviews_today = 0, 0
level_up_hours = []
# By default (without limits), it's possible to do lessons whenever
# there are lessons available; any limits on this are self-imposed
lesson_decider = lambda: len(self.fetch_lesson_unit_nums()) > 0
# Limiting both the Apprentice count and the review count
if self.max_apprentice and self.max_lessons_day:
lesson_decider = lambda: (
self.max_lessons_day - lessons_today > 0
and self.get_number_apprentice() < self.max_apprentice
and len(self.fetch_lesson_unit_nums()) > 0
)
# Limiting only the Apprentice count and doing as many reviews as
# possible (note that you can technically go over max. Apprentice count
# with my algorithm because I allow lessons to start even if the sum of
# the batch size and the Apprentice count is higher than the maximum)
elif self.max_apprentice:
lesson_decider = lambda: (
self.get_number_apprentice() < self.max_apprentice
and len(self.fetch_lesson_unit_nums()) > 0
)
# Limiting only the number of lessons completed each day
elif self.max_lessons_day:
lesson_decider = lambda: (
self.max_lessons_day - lessons_today > 0
and len(self.fetch_lesson_unit_nums()) > 0
)
while hour < self.hours_to_simulate: # There's some repetition here,
# but I prefer to write things out
# multiple times when it makes
# the process clearer
for hour_within_day in sorted(set(
list(self.review_times) + list(self.lesson_times)
)):
if hour_within_day in self.review_times:
available_review_nums = self.fetch_review_unit_nums(
hour + hour_within_day
)
if self.max_reviews_day:
review_ct = max(0, self.max_reviews_day - reviews_today)
available_review_nums = ( # Limits reviews based on the
# user-imposed maximum
available_review_nums[:review_ct]
)
for num in available_review_nums:
self.units[num].simulate_review(hour + hour_within_day)
reviews_today += len(available_review_nums)
# Completing a review session can result in kanji moving up
# to Guru 1, so we need to check whether the simulated user
# has leveled up
has_leveled_up = self.evaluate_level_up()
if has_leveled_up:
level_up_hours.append(hour + hour_within_day)
if hour_within_day in self.lesson_times:
self.update_unlocks() # Makes sure newly available lessons
# are represented in the first call
# to lesson_decider() below
while (lesson_decider()):
available_lesson_nums = (
self.fetch_lesson_unit_nums()
)[:self.lesson_batch] # Add lessons by batch
lessons_today += len(available_lesson_nums)
for num in available_lesson_nums:
self.units[num].simulate_lesson(
hour + hour_within_day
)
hour += 24
lessons_today, reviews_today = 0, 0
print(f"Fraction complete: {hour / self.hours_to_simulate}")
level_up_intervals = (
[level_up_hours[0]]
+ [level_up_hours[i + 1] - level_up_hours[i]
for i in range(len(level_up_hours) - 1)]
)
level_up_intervals_days = [each / 24 for each in level_up_intervals]
level_up_levels = range(1, len(level_up_hours) + 1)
fig, ax = plt.subplots()
plt.bar(level_up_levels, level_up_intervals_days)
plt.xlim((0, max(level_up_levels) + 1))
plt.xlabel("Level")
plt.ylabel("Days in level")
plt.show()
plt.close()
class Subject(object):
def __init__(self, json):
self.identity = json["id"]
self.classification = json["object"]
data = json["data"]
self.level = data["level"]
self.lesson_position = data["lesson_position"]
self.document_url = data["document_url"]
# Annoyingly, the SRS timings for the first couple levels are different
# (see https://knowledge.wanikani.com/wanikani/srs-stages/ for timings);
# I'm just going to store the timings in each Subject instance, which is
# inefficient but makes my life much easier
self.srs_hours = [
# Start at Apprentice 1 upon completing the associated lesson
4, # Apprentice 2
8, # Apprentice 3
24, # Apprentice 4
48, # Guru 1
168, # Guru 2
336, # Master
720, # Enlightened
2880 # Burned
]
if self.level <= 2:
self.srs_hours[0:4] = [2, 4, 8, 24]
# In my experience, the keys for dependencies don't appear in the JSON
# result unless there are actually dependencies; we need to handle these
# differently and assume they do not exist
keys = data.keys()
self.depends_on_me = []
self.i_depend_on = []
if "amalgamation_subject_ids" in keys:
self.depends_on_me = data["amalgamation_subject_ids"]
if "component_subject_ids" in keys:
self.i_depend_on = data["component_subject_ids"]
def cached_subject_info_fetch(
request_url,
parameters,
filename="wk_estimation_cache.pickle",
force_refresh=False
):
def subject_info_fetch(request_url, parameters):
subjects = {} # Because we don't know the number of subjects, we need
# to define this dynamically
# Recursively populate the subject information
def continue_fetch(request_url, parameters):
response = requests.get(url=request_url, headers=parameters)
json = response.json()
for item in json["data"]:
subjects[item["id"]] = Subject(item)
# The WaniKani API uses pagination to limit the size of responses;
# in order to fetch all subject information, we need to send another
# GET request with next_url, included in the response (str or None)
if json["pages"]["next_url"]:
continue_fetch(json["pages"]["next_url"], parameters)
continue_fetch(request_url, parameters)
return subjects
# This isn't completely safe, so make sure you have the permissions set up
# properly and don't trust random files from strangers
if os.path.exists(filename) and (not force_refresh):
with open(filename, "rb") as cache_file:
subjects = pickle.load(cache_file)
else:
subjects = subject_info_fetch(request_url, parameters)
with open(filename, "wb") as cache_file:
pickle.dump(subjects, cache_file)
return subjects
if __name__ == "__main__":
request_url = "https://api.wanikani.com/v2/subjects"
parameters = {
"Wanikani-Revision": "20170710",
"Authorization": "Bearer <your read-only API token>"
}
subjects = cached_subject_info_fetch(request_url, parameters)
for subject in subjects.values():
print(subject.__dict__)
simulation = Simulation(subjects)
avails = simulation.fetch_lesson_unit_nums()
for avail in avails:
print(subjects[avail].document_url, subjects[avail].lesson_position)
simulation.simulate()
I haven’t really tested this thoroughly.
Edit: The performance is horrendous relative to the script made by @indutny-wani. There are a number of places where I could avoid calculating things multiple times. It works for me, though, and I’m not a professional programmer.
Edit 2: I fixed the logic for determining review and lesson times, which were independent before but now allow for staggered lessons and reviews throughout the day. This gives a better estimate for the time required to level up, I think, and it seems to reflect the timings I’ve seen in some of the faster level 60 celebration posts:
The fastest possible time to reach level 60 is 352 days and 20 hours.