/**************************************************************************************** /**************************************************************************************** * Copyright (c) 2016 Jeffrey van Prehn <jvanprehn@gmail.com> * * * * This program is free software; you can redistribute it and/or modify it under * * the terms of the GNU General Public License as published by the Free Software * * Foundation; either version 3 of the License, or (at your option) any later * * version. * * * * This program is distributed in the hope that it will be useful, but WITHOUT ANY * * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License along with * * this program. If not, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.libanki.hooks; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.preference.PreferenceManager; import com.ichi2.anki.CollectionHelper; import com.ichi2.anki.R; import com.ichi2.anki.stats.StatsMetaInfo; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Decks; import com.ichi2.libanki.Stats; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Stack; import io.requery.android.database.sqlite.SQLiteDatabase; import timber.log.Timber; /** * Display forecast statistics based on a simulation of future reviews. * * Sequence diagram (https://www.websequencediagrams.com/): * Stats->+AdvancedStatistics: runFilter * AdvancedStatistics->+ReviewSimulator: simNreviews * loop dids * loop nIterations * loop cards * ReviewSimulator->+Review: newCard * Review->+NewCardSimulator: simulateNewCard * NewCardSimulator->-Review: tElapsed:int * Review-->-ReviewSimulator: SimulationResult, Review * * loop reviews * ReviewSimulator->+Review: simulateReview * Review->+EaseClassifier: simSingleReview * EaseClassifier->+Card:getType * Card-->-EaseClassifier:cardType:int * EaseClassifier-->-Review: ReviewOutcome * Review-->-ReviewSimulator: SimulationResult, Review[] * end * end * end * end * ReviewSimulator-->-AdvancedStatistics: SimulationResult * AdvancedStatistics-->-Stats: StatsMetaInfo * * %2F%2F Class diagram (http://yuml.me/diagram/scruffy/class/draw; http://yuml.me/edit/e0ad47bf): * [AdvancedStatistics] * [ReviewSimulator] * [StatsMetaInfo|mTitle:int;mType:int;mAxisTitles:int[];mValueLabels:int[];mColors:int[];] * [Settings|computeNDays:int;computeMaxError:double;simulateNIterations:int] * [Deck|-did:long;newPerDay:int;revPerDay:int] * [Card|-id:long;ivl:int;factor:double;lastReview:int;due:int;correct:int|setAll();getType()] * [Review|prob:double;tElapsed:int] * [SimulationResult|nReviews[CARD_TYPE][t];nInState[CARD_TYPE][t]] * [ReviewOutcome|prob:double] * [ReviewSimulator]uses -.->[CardIterator] * [ReviewSimulator]uses -.->[DeckFactory] * [ReviewSimulator]creates -.->[SimulationResult] * [ReviewSimulator]creates -.->[Review] * [Card]belongs to-.->[Deck] * [Review]updates -.->[SimulationResult] * [Review]]++-1>[Card] * [Review]creates -.->[Review] * [AdvancedStatistics]uses -.->[ReviewSimulator] * [Review]uses -.->[NewCardSimulator|nAddedToday:int;tAdd:int] * [Review]uses -.->[EaseClassifier|probabilities:double[CARD_TYPE][REVIEW_OUTCOME]] * [EaseClassifier]creates -.->[ReviewOutcome] * [ReviewOutcome]++-1>[Card] * [AdvancedStatistics]creates -.-> [StatsMetaInfo] */ public class AdvancedStatistics extends Hook { private static final int TIME = 0; //For indexing arrays. We have *_PLUS_1 because we often add //the time dimension at index 0. private static final int CARD_TYPE_COUNT = 3; private static final int CARD_TYPE_NEW = 0; private static final int CARD_TYPE_YOUNG = 1; private static final int CARD_TYPE_MATURE = 2; private static final int CARD_TYPE_NEW_PLUS_1 = 1; private static final int CARD_TYPE_YOUNG_PLUS_1 = 2; private static final int CARD_TYPE_MATURE_PLUS_1 = 3; private static final int REVIEW_TYPE_COUNT = 4; private static final int REVIEW_TYPE_LEARN = 0; private static final int REVIEW_TYPE_YOUNG = 1; private static final int REVIEW_TYPE_MATURE = 2; private static final int REVIEW_TYPE_RELEARN = 3; private static final int REVIEW_TYPE_COUNT_PLUS_1 = 5; private static final int REVIEW_TYPE_LEARN_PLUS_1 = 1; private static final int REVIEW_TYPE_YOUNG_PLUS_1 = 2; private static final int REVIEW_TYPE_MATURE_PLUS_1 = 3; private static final int REVIEW_TYPE_RELEARN_PLUS_1 = 4; private static final int REVIEW_OUTCOME_REPEAT = 0; private static final int REVIEW_OUTCOME_HARD = 1; private static final int REVIEW_OUTCOME_GOOD = 2; private static final int REVIEW_OUTCOME_EASY = 3; private static final int REVIEW_OUTCOME_REPEAT_PLUS_1 = 1; private static final int REVIEW_OUTCOME_HARD_PLUS_1 = 2; private static final int REVIEW_OUTCOME_GOOD_PLUS_1 = 3; private static final int REVIEW_OUTCOME_EASY_PLUS_1 = 4; private final ArrayUtils ArrayUtils = new ArrayUtils(); private final DeckFactory Decks = new DeckFactory(); private Settings Settings; @Override public Object runFilter(Object arg, Object... args) { Context context = (Context) args[1]; Settings = new Settings(context); return calculateDueAsMetaInfo((StatsMetaInfo) arg, (Stats.AxisType) args[0], context, (String) args[2]); } public static void install(Hooks h) { h.addHook("advancedStatistics", new AdvancedStatistics()); } public static void uninstall(Hooks h) { h.remHook("advancedStatistics", new AdvancedStatistics()); } /** * Determine forecast statistics based on a computation or simulation of future reviews. * Returns all information required by stats.java to plot the 'forecast' chart based on these statistics. * The chart will display: * - The forecasted number of reviews per review type (relearn, mature, young, learn) as bars * - The forecasted number of cards in each state (new, young, mature) as lines * @param metaInfo Object which will be filled with all information required by stats.java to plot the 'forecast' chart and returned by this method. * @param type Type of 'forecast' chart for which to determine forecast statistics. Accepted values: * Stats.TYPE_MONTH: Determine forecast statistics for next 30 days with 1-day chunks * Stats.TYPE_YEAR: Determine forecast statistics for next year with 7-day chunks * Stats.TYPE_LIFE: Determine forecast statistics for next 2 years with 30-day chunks * @param context Contains The collection which contains the decks to be simulated. * Also used for access to the database and access to the creation time of the collection. * The creation time of the collection is needed since due times of cards are relative to the creation time of the collection. * So we could pass mCol here. * @param dids Deck id's * @return @see #metaInfo */ private StatsMetaInfo calculateDueAsMetaInfo(StatsMetaInfo metaInfo, Stats.AxisType type, Context context, String dids) { //To indicate that we calculated the statistics so that Stats.java knows that it shouldn't display the standard Forecast chart. metaInfo.setStatsCalculated(true); Collection mCol = CollectionHelper.getInstance().getCol(context); double[][] mSeriesList; int[] mValueLabels; int[] mColors; int[] mAxisTitles; int mMaxCards = 0; int mMaxElements; double mFirstElement; double mLastElement = 0; int mZeroIndex = 0; double[][] mCumulative; double mMcount; mValueLabels = new int[] { R.string.statistics_relearn, R.string.statistics_mature, R.string.statistics_young, R.string.statistics_learn}; mColors = new int[] { R.attr.stats_relearn, R.attr.stats_mature, R.attr.stats_young, R.attr.stats_learn}; mAxisTitles = new int[] { type.ordinal(), R.string.stats_cards, R.string.stats_cumulative_cards }; PlottableSimulationResult simuationResult = calculateDueAsPlottableSimulationResult(type, mCol, dids); ArrayList<int[]> dues = simuationResult.getNReviews(); mSeriesList = new double[REVIEW_TYPE_COUNT_PLUS_1][dues.size()]; for (int t = 0;t < dues.size(); t++) { int[] data = dues.get(t); int nReviews = data[REVIEW_TYPE_LEARN_PLUS_1] + data[REVIEW_TYPE_YOUNG_PLUS_1] + data[REVIEW_TYPE_MATURE_PLUS_1] + data[REVIEW_TYPE_RELEARN_PLUS_1]; if(nReviews > mMaxCards) mMaxCards = nReviews; //Y-Axis: Max. value //In the bar-chart, the bars will be stacked on top of each other. //For the i^{th} bar counting from the bottom we therefore have to //provide the sum of the heights of the i^{th} bar and all bars below it. mSeriesList[TIME][t] = data[TIME]; //X-Axis: Day / Week / Month mSeriesList[REVIEW_TYPE_LEARN_PLUS_1][t] = data[REVIEW_TYPE_LEARN_PLUS_1] + data[REVIEW_TYPE_YOUNG_PLUS_1] + data[REVIEW_TYPE_MATURE_PLUS_1] + data[REVIEW_TYPE_RELEARN_PLUS_1]; //Y-Axis: # Cards mSeriesList[REVIEW_TYPE_YOUNG_PLUS_1][t] = data[REVIEW_TYPE_LEARN_PLUS_1] + data[REVIEW_TYPE_YOUNG_PLUS_1] + data[REVIEW_TYPE_MATURE_PLUS_1]; //Y-Axis: # Mature cards mSeriesList[REVIEW_TYPE_MATURE_PLUS_1][t] = data[REVIEW_TYPE_LEARN_PLUS_1] + data[REVIEW_TYPE_YOUNG_PLUS_1]; //Y-Axis: # Young mSeriesList[REVIEW_TYPE_RELEARN_PLUS_1][t] = data[REVIEW_TYPE_LEARN_PLUS_1]; //Y-Axis: # Learn if(data[TIME] > mLastElement) mLastElement = data[TIME]; //X-Axis: Max. value (only for TYPE_LIFE) if(data[TIME] == 0){ mZeroIndex = t; //Because we retrieve dues in the past and we should not cumulate them } } mMaxElements = dues.size()-1; //# X values switch (type) { case TYPE_MONTH: mLastElement = 31; //X-Axis: Max. value break; case TYPE_YEAR: mLastElement = 52; //X-Axis: Max. value break; default: } mFirstElement = 0; //X-Axis: Min. value mCumulative = simuationResult.getNInState(); //Day starting at mZeroIndex, Cumulative # cards mMcount = mCumulative[CARD_TYPE_NEW_PLUS_1][mCumulative[CARD_TYPE_NEW_PLUS_1].length-1] + //Y-Axis: Max. cumulative value mCumulative[CARD_TYPE_YOUNG_PLUS_1][mCumulative[CARD_TYPE_YOUNG_PLUS_1].length-1] + mCumulative[CARD_TYPE_MATURE_PLUS_1][mCumulative[CARD_TYPE_MATURE_PLUS_1].length-1]; //some adjustments to not crash the chartbuilding with empty data if(mMaxElements == 0){ mMaxElements = 10; } if(mMcount == 0){ mMcount = 10; } if(mFirstElement == mLastElement){ mFirstElement = 0; mLastElement = 6; } if(mMaxCards == 0) mMaxCards = 10; metaInfo.setmDynamicAxis(true); metaInfo.setmHasColoredCumulative(true); metaInfo.setmType(type); metaInfo.setmTitle(R.string.stats_forecast); metaInfo.setmBackwards(true); metaInfo.setmValueLabels(mValueLabels); metaInfo.setmColors(mColors); metaInfo.setmAxisTitles(mAxisTitles); metaInfo.setmMaxCards(mMaxCards); metaInfo.setmMaxElements(mMaxElements); metaInfo.setmFirstElement(mFirstElement); metaInfo.setmLastElement(mLastElement); metaInfo.setmZeroIndex(mZeroIndex); metaInfo.setmCumulative(mCumulative); metaInfo.setmMcount(mMcount); metaInfo.setmSeriesList(mSeriesList); metaInfo.setDataAvailable(dues.size() > 0); return metaInfo; } /** * Determine forecast statistics based on a computation or simulation of future reviews and returns the results of the simulation. * @param type @see #calculateDueOriginal(StatsMetaInfo, int, Context, String) * @param mCol @see #calculateDueOriginal(StatsMetaInfo, int, Context, String) * @param dids @see #calculateDueOriginal(StatsMetaInfo, int, Context, String) * @return An object containing the results of the simulation: * - The forecasted number of reviews per review type (relearn, mature, young, learn) * - The forecasted number of cards in each state (new, young, mature) */ private PlottableSimulationResult calculateDueAsPlottableSimulationResult(Stats.AxisType type, Collection mCol, String dids) { int end = 0; int chunk = 0; switch (type) { case TYPE_MONTH: end = 31; chunk = 1; break; case TYPE_YEAR: end = 52; chunk = 7; break; case TYPE_LIFE: end = 24; chunk = 30; break; } ArrayList<int[]> dues = new ArrayList<>(); EaseClassifier classifier = new EaseClassifier(mCol.getDb().getDatabase()); ReviewSimulator reviewSimulator = new ReviewSimulator(mCol.getDb().getDatabase(), classifier, end, chunk); TodayStats todayStats = new TodayStats(mCol.getDb().getDatabase(), Settings.getDayStartCutoff((int)mCol.getCrt())); long t0 = System.currentTimeMillis(); SimulationResult simulationResult = reviewSimulator.simNreviews(Settings.getToday((int)mCol.getCrt()), mCol.getDecks(), dids, todayStats); long t1 = System.currentTimeMillis(); Timber.d("Simulation of all decks took: " + (t1 - t0) + " ms"); int[][] nReviews = ArrayUtils.transposeMatrix(simulationResult.getNReviews()); int[][] nInState = ArrayUtils.transposeMatrix(simulationResult.getNInState()); //Append row with zeros and transpose to make it the same dimension as nReviews //int[][] nInState = simulationResult.getNInState(); //if(ArrayUtils.nCols(nInState) > 0) // nInState = ArrayUtils.append(nInState, new int[ArrayUtils.nCols(nInState)], 1); // Forecasted number of reviews for(int i = 0; i<nReviews.length; i++) { dues.add(new int[] { i, //Time nReviews[i][REVIEW_TYPE_LEARN], nReviews[i][REVIEW_TYPE_YOUNG], nReviews[i][REVIEW_TYPE_MATURE], nReviews[i][REVIEW_TYPE_RELEARN] }); } // small adjustment for a proper chartbuilding if (dues.size() == 0 || dues.get(0)[0] > 0) { dues.add(0, new int[] { 0, 0, 0, 0, 0 }); } if (type == Stats.AxisType.TYPE_LIFE && dues.size() < 2) { end = 31; } if (type != Stats.AxisType.TYPE_LIFE && dues.get(dues.size() - 1)[0] < end) { dues.add(new int[] { end, 0, 0, 0, 0 }); } else if (type == Stats.AxisType.TYPE_LIFE && dues.size() < 2) { dues.add(new int[] { Math.max(12, dues.get(dues.size() - 1)[0] + 1), 0, 0, 0, 0 }); } double[][] nInStateCum = new double[dues.size()][]; for(int i = 0; i<dues.size(); i++) { if(i < nInState.length) { nInStateCum[i] = new double[] { i, 0, //Y-Axis: Relearn = 0 (we can't say 'we know x relearn cards on day d') //nInState[i][0] + nInState[i][1] + nInState[i][2], //Y-Axis: New + Young + Mature //nInState[i][0] + nInState[i][1], //Y-Axis: New + Young //nInState[i][0], //Y-Axis: New nInState[i][CARD_TYPE_MATURE], //Y-Axis: Mature nInState[i][CARD_TYPE_YOUNG], //Y-Axis: Young nInState[i][CARD_TYPE_NEW], //Y-Axis: New }; } else { if(i==0) nInStateCum[i] = new double[] { i, 0,0,0,0 }; else nInStateCum[i] = nInStateCum[i-1]; } } //Append columns to make it the same dimension as dues //if(dues.size() > nInState.length) { // nInState = ArrayUtils.append(nInState, nInState[nInState.length-1], dues.size() - nInState.length); //} return new PlottableSimulationResult(dues, ArrayUtils.transposeMatrix(nInStateCum)); } private class Card { private int ivl; private double factor; private int lastReview; private int due; private int correct; private long id; @Override public String toString() { return "Card [ivl=" + ivl + ", factor=" + factor + ", due=" + due + ", correct=" + correct + ", id=" + id + "]"; } public Card(long id, int ivl, int factor, int due, int correct, int lastReview) { super(); this.id = id; this.ivl = ivl; this.factor = factor / 1000.0; this.due = due; this.correct = correct; this.lastReview = lastReview; } public void setAll(long id, int ivl, int factor, int due, int correct, int lastReview) { this.id = id; this.ivl = ivl; this.factor = factor / 1000.0; this.due = due; this.correct = correct; this.lastReview = lastReview; } public void setAll(Card card) { this.id = card.id; this.ivl = card.ivl; this.factor = card.factor; this.due = card.due; this.correct = card.correct; this.lastReview = card.lastReview; } public long getId() { return id; } public int getIvl() { return ivl; } public void setIvl(int ivl) { this.ivl = ivl; } public double getFactor() { return factor; } public void setFactor(double factor) { this.factor = factor; } public int getDue() { return due; } public void setDue(int due) { this.due = due; } /** * Type of the card, based on the interval. * @return CARD_TYPE_NEW if interval = 0, CARD_TYPE_YOUNG if interval 1-20, CARD_TYPE_MATURE if interval >= 20 */ public int getType() { if(ivl == 0) { return CARD_TYPE_NEW; } else if (ivl >= 21) { return CARD_TYPE_MATURE; } else { return CARD_TYPE_YOUNG; } } public int getCorrect() { return correct; } public void setCorrect(int correct) { this.correct = correct; } public int getLastReview() { return lastReview; } public void setLastReview(int lastReview) { this.lastReview = lastReview; } } private class DeckFactory { public Deck createDeck(long did, Decks decks) { Timber.d("Trying to get deck settings for deck with id=" + did); JSONObject conf = decks.confForDid(did); int newPerDay = Settings.getMaxNewPerDay(); int revPerDay = Settings.getMaxReviewsPerDay(); int initialFactor = Settings.getInitialFactor(); try { if (conf.getInt("dyn") == 0) { revPerDay = conf.getJSONObject("rev").getInt("perDay"); newPerDay = conf.getJSONObject("new").getInt("perDay"); initialFactor = conf.getJSONObject("new").getInt("initialFactor"); Timber.d("rev.perDay=" + revPerDay); Timber.d("new.perDay=" + newPerDay); Timber.d("new.initialFactor=" + initialFactor); } else { Timber.d("dyn=" + conf.getInt("dyn")); } } catch (JSONException e) { throw new RuntimeException(e); } return new Deck(did, newPerDay, revPerDay, initialFactor); } } /** * Stores settings that are deck-specific. */ private class Deck { private long did; private int newPerDay; private int revPerDay; private int initialFactor; public Deck(long did, int newPerDay, int revPerDay, int initialFactor) { this.did = did; this.newPerDay = newPerDay; this.revPerDay = revPerDay; this.initialFactor = initialFactor; } public long getDid() { return did; } public int getNewPerDay() { return newPerDay; } public int getRevPerDay() { return revPerDay; } public int getInitialFactor() { return initialFactor; } } private class CardIterator { Cursor cur; private final int today; private Deck deck; public CardIterator(SQLiteDatabase db, int today, Deck deck) { this.today = today; this.deck = deck; long did = deck.getDid(); String query; query = "SELECT id, due, ivl, factor, type, reps " + "FROM cards " + "WHERE did IN (" + did + ") " + "order by id;"; Timber.d("Forecast query: %s", query); cur = db.rawQuery(query, null); } public boolean moveToNext() { return cur.moveToNext(); } public void current(Card card) { card.setAll(cur.getLong(0), //Id cur.getInt(5) == 0 ? 0 : cur.getInt(2), //reps = 0 ? 0 : card interval cur.getInt(3) > 0 ? cur.getInt(3) : deck.getInitialFactor(), //factor Math.max(cur.getInt(1) - today, 0), //due 1, //correct -1 //lastreview ); } public void close() { if (cur != null && !cur.isClosed()) cur.close(); } } /** * Based on the current type of the card (@see Card#getType()), determines the interval of the card after review and the probability of the card having that interval after review. * This is done using a discrete probability distribution, which is built on construction. * For each possible current type of the card, it gives the probability of each possible review outcome (repeat, hard, good, easy). * The review outcome determines the next interval of the card. * * If the review outcome is specified by the caller, the next interval of the card will be determined based on the review outcome * and the probability will be fetched from the probability distribution. * If the review outcome is not specified by the caller, the review outcome will be sampled randomly from the probability distribution * and the probability will be 1. */ private class EaseClassifier { private final Random random; private final SQLiteDatabase db; private double[][] probabilities; private double[][] probabilitiesCumulative; //# Prior that half of new cards are answered correctly private final int[] priorNew = {5, 0, 5, 0}; //half of new cards are answered correctly private final int[] priorYoung = {1, 0, 9, 0}; //90% of young cards get "good" response private final int[] priorMature = {1, 0, 9, 0}; //90% of mature cards get "good" response //TODO: should we determine these per deck or over decks? //Per deck means less data, but tuned to deck. //Over decks means more data, but not tuned to deck. private final String queryBaseNew = "select " + "count() as N, " + "sum(case when ease=1 then 1 else 0 end) as repeat, " + "0 as hard, " //Doesn't occur in query_new + "sum(case when ease=2 then 1 else 0 end) as good, " + "sum(case when ease=3 then 1 else 0 end) as easy " + "from revlog "; private final String queryBaseYoungMature = "select " + "count() as N, " + "sum(case when ease=1 then 1 else 0 end) as repeat, " + "sum(case when ease=2 then 1 else 0 end) as hard, " //Doesn't occur in query_new + "sum(case when ease=3 then 1 else 0 end) as good, " + "sum(case when ease=4 then 1 else 0 end) as easy " + "from revlog "; private final String queryNew = queryBaseNew + "where type=0;"; private final String queryYoung = queryBaseYoungMature + "where type=1 and lastIvl < 21;"; private final String queryMature = queryBaseYoungMature + "where type=1 and lastIvl >= 21;"; public EaseClassifier(SQLiteDatabase db) { this.db = db; singleReviewOutcome = new ReviewOutcome(null, 0); long t0 = System.currentTimeMillis(); calculateCumProbabilitiesForNewEasePerCurrentEase(); long t1 = System.currentTimeMillis(); Timber.d("Calculating probability distributions took: " + (t1 - t0) + " ms"); Timber.d("new\t\t" + Arrays.toString(this.probabilities[0])); Timber.d("young\t\t" + Arrays.toString(this.probabilities[1])); Timber.d("mature\t" + Arrays.toString(this.probabilities[2])); Timber.d("Cumulative new\t\t" + Arrays.toString(this.probabilitiesCumulative[0])); Timber.d("Cumulative young\t\t" + Arrays.toString(this.probabilitiesCumulative[1])); Timber.d("Cumulative mature\t" + Arrays.toString(this.probabilitiesCumulative[2])); random = new Random(); } private double[] cumsum(double[] p) { double[] q = new double[4]; q[0] = p[0]; q[1] = q[0] + p[1]; q[2] = q[1] + p[2]; q[3] = q[2] + p[3]; return q; } private void calculateCumProbabilitiesForNewEasePerCurrentEase() { this.probabilities = new double[3][]; this.probabilitiesCumulative = new double[3][]; this.probabilities[CARD_TYPE_NEW] = calculateProbabilitiesForNewEaseForCurrentEase(queryNew, priorNew); this.probabilities[CARD_TYPE_YOUNG] = calculateProbabilitiesForNewEaseForCurrentEase(queryYoung, priorYoung); this.probabilities[CARD_TYPE_MATURE] = calculateProbabilitiesForNewEaseForCurrentEase(queryMature, priorMature); this.probabilitiesCumulative[CARD_TYPE_NEW] = cumsum(this.probabilities[CARD_TYPE_NEW]); this.probabilitiesCumulative[CARD_TYPE_YOUNG] = cumsum(this.probabilities[CARD_TYPE_YOUNG]); this.probabilitiesCumulative[CARD_TYPE_MATURE] = cumsum(this.probabilities[CARD_TYPE_MATURE]); } /** * Given a query which selects the frequency of each review outcome for the current type of the card, * and an array containing the prior frequency of each review outcome for the current type of the card, * it gives the probability of each possible review outcome (repeat, hard, good, easy). * @param queryNewEaseCountForCurrentEase Query which selects the frequency of each review outcome for the current type of the card. * @param prior Array containing the prior frequency of each review outcome for the current type of the card. * @return The probability of each possible review outcome (repeat, hard, good, easy). */ private double[] calculateProbabilitiesForNewEaseForCurrentEase(String queryNewEaseCountForCurrentEase, int[] prior) { Cursor cur = null; int[] freqs = new int[] { prior[REVIEW_OUTCOME_REPEAT], prior[REVIEW_OUTCOME_HARD], prior[REVIEW_OUTCOME_GOOD], prior[REVIEW_OUTCOME_EASY] }; int n = prior[REVIEW_OUTCOME_REPEAT] + prior[REVIEW_OUTCOME_HARD] + prior[REVIEW_OUTCOME_GOOD] + prior[REVIEW_OUTCOME_EASY]; try { cur = db.rawQuery(queryNewEaseCountForCurrentEase, null); cur.moveToNext(); freqs[REVIEW_OUTCOME_REPEAT] += cur.getInt(REVIEW_OUTCOME_REPEAT_PLUS_1); //Repeat freqs[REVIEW_OUTCOME_HARD] += cur.getInt(REVIEW_OUTCOME_HARD_PLUS_1); //Hard freqs[REVIEW_OUTCOME_GOOD] += cur.getInt(REVIEW_OUTCOME_GOOD_PLUS_1); //Good freqs[REVIEW_OUTCOME_EASY] += cur.getInt(REVIEW_OUTCOME_EASY_PLUS_1); //Easy int nQuery = cur.getInt(0); //N n += nQuery; } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } return new double[] { freqs[REVIEW_OUTCOME_REPEAT] / (double) n, freqs[REVIEW_OUTCOME_HARD] / (double) n, freqs[REVIEW_OUTCOME_GOOD] / (double) n, freqs[REVIEW_OUTCOME_EASY] / (double) n }; } private int draw(double[] p) { return searchsorted(p, random.nextDouble()); } private int searchsorted(double[] p, double random) { if(random <= p[0]) return 0; if(random <= p[1]) return 1; if(random <= p[2]) return 2; return 3; } private ReviewOutcome singleReviewOutcome; public ReviewOutcome simSingleReview(Card c){ int type = c.getType(); int outcome = draw(probabilitiesCumulative[type]); applyOutcomeToCard(c, outcome); singleReviewOutcome.setAll(c, 1); return singleReviewOutcome; } public ReviewOutcome simSingleReview(Card c, int outcome) { int c_type = c.getType(); //For first review, re-use current card to prevent creating too many objects applyOutcomeToCard(c, outcome); singleReviewOutcome.setAll(c, probabilities[c_type][outcome]); return singleReviewOutcome; } private void applyOutcomeToCard(Card c, int outcome) { int type = c.getType(); int ivl = c.getIvl(); double factor = c.getFactor(); if(type == 0) { if (outcome <= 2) ivl = 1; else ivl = 4; } else { switch(outcome) { case REVIEW_OUTCOME_REPEAT: ivl = 1; //factor = Math.max(1300, factor - 200); break; case REVIEW_OUTCOME_HARD: ivl *= 1.2; break; case REVIEW_OUTCOME_GOOD: ivl *= 1.2 * factor; break; case REVIEW_OUTCOME_EASY: default: ivl *= 1.2 * 2. * factor; break; } } c.setIvl(ivl); c.setCorrect((outcome > 0) ? 1 : 0); //c.setTypetype); //c.setIvl(60); //c.setFactor(factor); } } public class TodayStats { private Map<Long, Integer> nLearnedPerDeckId = new HashMap<Long, Integer>(); public TodayStats(SQLiteDatabase db, long dayStartCutoff) { Cursor cur = null; String query = "select cards.did, "+ "sum(case when revlog.type = 0 then 1 else 0 end)"+ /* learning */ " from revlog, cards where revlog.cid = cards.id and revlog.id > " + dayStartCutoff + " group by cards.did"; Timber.d("AdvancedStatistics.TodayStats query: %s", query); try { cur = db.rawQuery(query, null); while(cur.moveToNext()) { nLearnedPerDeckId.put(cur.getLong(0), cur.getInt(1)); } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } } public int getNLearned(long did) { if(nLearnedPerDeckId.containsKey(did)) { return nLearnedPerDeckId.get(did); } else { return 0; } } } public class NewCardSimulator { private int nAddedToday; private int tAdd; public NewCardSimulator() { reset(0); } public int simulateNewCard(Deck deck) { nAddedToday++; int tElapsed = tAdd; //differs from online if (nAddedToday >= deck.getNewPerDay()) { tAdd++; nAddedToday = 0; } return tElapsed; } public void reset(int nAddedToday) { this.nAddedToday = nAddedToday; this.tAdd = 0; } } /** * Simulates future card reviews, keeping track of statistics and returns those as SimulationResult. * * A simulation is run for each of the specified decks using the settings (max # cards per day, max # reviews per day, initial factor for new cards) for that deck. * Within each deck the simulation consists of one or more simulations of each card within that deck. * A simulation of a single card means simulating future card reviews starting from now until the end of the simulation window as specified by nTimeBins and timeBinLength. * * A review of a single card is run by the specified classifier. */ private class ReviewSimulator { private final SQLiteDatabase db; private final EaseClassifier classifier; //TODO: also exists in Review private final int nTimeBins; private final int timeBinLength; private final int tMax; private final NewCardSimulator newCardSimulator = new NewCardSimulator(); public ReviewSimulator(SQLiteDatabase db, EaseClassifier classifier, int nTimeBins, int timeBinLength) { this.db = db; this.classifier = classifier; this.nTimeBins = nTimeBins; this.timeBinLength = timeBinLength; this.tMax = this.nTimeBins * this.timeBinLength; } public SimulationResult simNreviews(int today, Decks decks, String didsStr, TodayStats todayStats) { SimulationResult simulationResultAggregated = new SimulationResult(nTimeBins, timeBinLength, SimulationResult.DOUBLE_TO_INT_MODE_ROUND); long[] dids = ArrayUtils.stringToLongArray(didsStr); int nIterations = Settings.getSimulateNIterations(); double nIterationsInv = 1.0 / nIterations; for(long did : dids) { for(int iteration = 0; iteration < nIterations; iteration++) { newCardSimulator.reset(todayStats.getNLearned(did)); simulationResultAggregated.add(simNreviews(today, Decks.createDeck(did, decks)), nIterationsInv); } } return simulationResultAggregated; } private SimulationResult simNreviews(int today, Deck deck) { SimulationResult simulationResult; //we schedule a review if the number of reviews has not yet reached the maximum # reviews per day //If we compute the simulationresult, we keep track of the average number of reviews //Since it's the average, it can be a non-integer //Adding a review to a non-integer can make it exceed the maximum # reviews per day, but not by 1 or more //So if we take the floor when displaying it, we will display the maximum # reviews if(Settings.getComputeNDays() > 0) simulationResult = new SimulationResult(nTimeBins, timeBinLength, SimulationResult.DOUBLE_TO_INT_MODE_FLOOR); else simulationResult = new SimulationResult(nTimeBins, timeBinLength, SimulationResult.DOUBLE_TO_INT_MODE_ROUND); //nSmooth=1 //TODO: //Forecasted final state of deck //finalIvl = np.empty((nSmooth, nCards), dtype='f8') Timber.d("today: " + today); Stack<Review> reviews = new Stack<>(); ArrayList<Review> reviewList = new ArrayList<>(); //By having simulateReview add future reviews depending on which simulation of this card this is (the nth) we can: //1. Do monte carlo simulation if we add nIterations future reviews if n = 1 // We don't do it this way. Instead we do this by having tis method [simNreviews] called nIterations times. // The reason is that in that way we take into account the dependency between cards correctly, since we do // for each iteration... for each card // If we would do for each card... for each iteration... we would not take it into account correctly. // We would not schedule new cards on a particular day if on average the new card limit would have been exceeded // in simulations of previous cards. //2. Do a complete traversal of the future reviews tree if we add k future reviews for all n // We accept the drawback as mentioned in (1). //3. Do any combination of these (controlled by computeNDays and computeMaxError) Card card = new Card(0, 0, 0, 0, 0, 0); CardIterator cardIterator = null; Review review = new Review(deck, simulationResult, classifier, reviews, reviewList); try { cardIterator = new CardIterator(db, today, deck); //int cardN = 0; while (cardIterator.moveToNext()) { cardIterator.current(card); review.newCard(card, newCardSimulator); if (review.getT() < tMax) reviews.push(review); //Timber.d("Card started: " + cardN); while (!reviews.isEmpty()) { reviews.pop().simulateReview(); } //Timber.d("Card done: " + cardN++); } } finally { if(cardIterator != null) cardIterator.close(); } ArrayUtils.formatMatrix("nReviews", simulationResult.getNReviews(), "%04d "); ArrayUtils.formatMatrix("nInState", simulationResult.getNInState(), "%04d "); return simulationResult; } } /** * Stores global settings. */ private class Settings { private final int computeNDays; private final double computeMaxError; private final int simulateNIterations; public Settings(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); computeNDays = prefs.getInt("advanced_forecast_stats_compute_n_days", 0); int computePrecision = prefs.getInt("advanced_forecast_stats_compute_precision", 90); computeMaxError = (100-computePrecision)/100.0; simulateNIterations = prefs.getInt("advanced_forecast_stats_mc_n_iterations", 1); Timber.d("computeNDays: " + computeNDays); Timber.d("computeMaxError: " + computeMaxError); Timber.d("simulateNIterations: " + simulateNIterations); } public int getComputeNDays() { return computeNDays; } public double getComputeMaxError() { return computeMaxError; } public int getSimulateNIterations() { return simulateNIterations; } /** * @return Maximum number of new cards per day which will be used if it cannot be read from Deck settings. */ public int getMaxNewPerDay() { return 20; } /** * @return Maximum number of reviews per day which will be used if it cannot be read from Deck settings. */ public int getMaxReviewsPerDay() { return 10000; } /** * * @return Factor which will be used if it cannot be read from Deck settings. */ public int getInitialFactor() { return 2500; } public long getNow() { //return 1451223980146L; return System.currentTimeMillis(); } /** * Today. * @param collectionCreatedTime The difference, measured in seconds, between midnight, January 1, 1970 UTC and the time at which the collection was created. * @return Today in days counted from the time at which the collection was created */ public int getToday(int collectionCreatedTime) { Timber.d("Collection creation timestamp: " + collectionCreatedTime); int currentTime = (int) (getNow() / 1000); Timber.d("Now: " + currentTime); return (currentTime - collectionCreatedTime) / getNSecsPerDay(); } /** * Beginning of today. * @param collectionCreatedTime The difference, measured in seconds, between midnight, January 1, 1970 UTC and the time at which the collection was created. * @return The beginning of today in milliseconds counted from the time at which the collection was created */ public long getDayStartCutoff (int collectionCreatedTime) { long today = getToday(collectionCreatedTime); return (collectionCreatedTime + (today * getNSecsPerDay())) * 1000; } public int getNSecsPerDay() { return 86400; } } private class ArrayUtils { public int[][] createIntMatrix(int m, int n) { int[][] matrix = new int[m][]; for(int i=0; i<m; i++) { matrix[i] = new int[n]; for(int j=0; j<n; j++) matrix[i][j] = 0; } return matrix; } public int[][] toIntMatrix(double[][] doubleMatrix, int doubleToIntMode) { int m = doubleMatrix.length; if(m == 0) return new int[0][]; int n = doubleMatrix[1].length; int[][] intMatrix = new int[m][]; for(int i=0; i<m; i++) { intMatrix[i] = new int[n]; for(int j=0; j<n; j++) { if (doubleToIntMode == SimulationResult.DOUBLE_TO_INT_MODE_ROUND) intMatrix[i][j] = (int) Math.round(doubleMatrix[i][j]); else intMatrix[i][j] = (int) doubleMatrix[i][j]; } } return intMatrix; } public double[][] createDoubleMatrix(int m, int n) { double[][] matrix = new double[m][]; for(int i=0; i<m; i++) { matrix[i] = new double[n]; for(int j=0; j<n; j++) matrix[i][j] = 0; } return matrix; } public<T> T[] append(T[] arr, T element, int n) { final int N0 = arr.length; final int N1 = N0 + n; arr = Arrays.copyOf(arr, N1); for(int N = N0; N < N1; N++) arr[N] = element; return arr; } public int nRows(int[][] matrix) { return matrix.length; } public int nCols(int[][] matrix) { if(matrix.length == 0) return 0; return matrix[0].length; } public long[] stringToLongArray(String s) { String[] split = s.substring(1, s.length() - 1).split(", "); long[] arr = new long[split.length]; for(int i = 0; i<split.length; i++) arr[i] = Long.parseLong(split[i]); return arr; } public int[][] transposeMatrix(int[][] matrix) { if (matrix.length == 0) return matrix; int m = matrix.length; int n = matrix[0].length; int transpose[][] = new int[n][m]; int c, d; for ( c = 0 ; c < m ; c++ ) { for ( d = 0 ; d < n ; d++ ) transpose[d][c] = matrix[c][d]; } return transpose; } public double[][] transposeMatrix(double[][] matrix) { if (matrix.length == 0) return matrix; int m = matrix.length; int n = matrix[0].length; double transpose[][] = new double[n][m]; int c, d; for ( c = 0 ; c < m ; c++ ) { for ( d = 0 ; d < n ; d++ ) transpose[d][c] = matrix[c][d]; } return transpose; } public void formatMatrix(String matrixName, int[][] matrix, String format) { StringBuilder s = new StringBuilder(); s.append(matrixName); s.append(":"); s.append(System.getProperty("line.separator")); for (int[] aMatrix : matrix) { for (int j = 0; j < aMatrix.length; j++) { s.append(String.format(format, aMatrix[j])); } s.append(System.getProperty("line.separator")); } Timber.d(s.toString()); } } /** * Statistics generated by simulations of Reviews. */ private class SimulationResult { public static final int DOUBLE_TO_INT_MODE_FLOOR = 0; public static final int DOUBLE_TO_INT_MODE_ROUND = 1; private final int doubleToIntMode; private final int nTimeBins; private final int timeBinLength; private final int nDays; /** * Forecasted number of reviews per time bin (a time bin contains statistics for 1 or a multiple of days) * First dimension: * 0 = Learn * 1 = Young * 2 = Mature * 3 = Relearn * Second dimension: time */ private final double[][] nReviews; /** * Forecasted number of reviews per day. * @see #nReviews */ private final double[][] nReviewsPerDay; /** * Forecasted number of cards per state * First dimension: * 0 = New * 1 = Young * 2 = Mature * Second dimension: time */ private final double[][] nInState; /** * Create an empty SimulationResult. * @param nTimeBins Number of time bins. * @param timeBinLength Length of 1 time bin in days. */ public SimulationResult(int nTimeBins, int timeBinLength, int doubleToIntMode) { nReviews = ArrayUtils.createDoubleMatrix(REVIEW_TYPE_COUNT, nTimeBins); nReviewsPerDay = ArrayUtils.createDoubleMatrix(REVIEW_TYPE_COUNT, nTimeBins * timeBinLength); nInState = ArrayUtils.createDoubleMatrix(CARD_TYPE_COUNT, nTimeBins); this.nTimeBins = nTimeBins; this.timeBinLength = timeBinLength; this.nDays = nTimeBins * timeBinLength; this.doubleToIntMode = doubleToIntMode; } public int getnDays() { return nDays; } /** * Adds the statistics generated by another simulation to the current statistics. * Use to gather statistics over decks. * @param res2Add Statistics to be added to the current statistics. */ public void add(SimulationResult res2Add, double prob) { int[][] nReviews = res2Add.getNReviews(); int[][] nInState = res2Add.getNInState(); for(int i = 0; i < nReviews.length; i++) for(int j = 0; j < nReviews[i].length; j++) this.nReviews[i][j] += nReviews[i][j] * prob; //This method is only used to aggregate over decks //We do not update nReviewsPerDay since it is not needed for the SimulationResult aggregated over decks. for(int i = 0; i < nInState.length; i++) for(int j = 0; j < nInState[i].length; j++) this.nInState[i][j] += nInState[i][j] * prob; } public int[][] getNReviews() { return ArrayUtils.toIntMatrix(nReviews, doubleToIntMode); } public int[][] getNInState() { return ArrayUtils.toIntMatrix(nInState, doubleToIntMode); } /** * Request the number of reviews which have been simulated so far at a particular day * (to check if the 'maximum number of reviews per day' limit has been reached). * If we are doing more than one simulation this means the average number of reviews * simulated so far at the requested day (over simulations). * More correct would be simulating all (or several) possible futures and returning here the number of * reviews done in the future currently being simulated. * * But that would change the entire structure of the simulation (which is now in a for each card loop). * @param tElapsed Day for which the number of reviews is requested. * @return Number of reviews of young and mature cards simulated at time tElapsed. * This excludes new cards and relearns as they don't count towards the limit. */ public int nReviewsDoneToday(int tElapsed) { return (int)(nReviewsPerDay[REVIEW_TYPE_YOUNG][tElapsed] + nReviewsPerDay[REVIEW_TYPE_MATURE][tElapsed]); } /** * Increment the count 'number of reviews of card with type cardType' with one at day t. * @param cardType Card type * @param t Day for which to increment */ public void incrementNReviews(int cardType, int t, double prob) { nReviews[cardType][t / timeBinLength]+= prob; nReviewsPerDay[cardType][t]+= prob; } /** * Increment the count 'number of cards in the state of the given card' with one between tFrom and tTo. * @param card Card from which to read the state. * @param tFrom The first day for which to update the state. * @param tTo The day after the last day for which to update the state. */ public void updateNInState(Card card, int tFrom, int tTo, double prob) { int cardType = card.getType(); int t0 = tFrom / timeBinLength; int t1 = tTo / timeBinLength; for(int t = t0; t < t1; t++) if(t < nTimeBins) { nInState[cardType][t]+= prob; } else { return; } } /** * Increment the count 'number of cards in the state of the given card' with one between tFrom and tTo and * replace state set during last review (contained in prevCard) with state set during new review (contained in card). * * This is necessary because we want to display the state at the end of each time bin. * So if two reviews occurred in one time bin, that time bin should display the * last review which occurred in it. * * @see #updateNInState(Card, int, int, double) */ public void updateNInState(Card prevCard, Card card, int tFrom, int tTo, double prob) { int lastReview = prevCard.getLastReview(); int prevCardType = prevCard.getType(); int cardType = card.getType(); int t0 = tFrom / timeBinLength; int t1 = Math.min(lastReview, tTo) / timeBinLength; //Replace state set during last review for(int t = t0; t < t1; t++) if(t < nTimeBins) { nInState[prevCardType][t]-= prob; } else { break; } t1 = tTo / timeBinLength; //With state set during new review for(int t = t0; t < t1; t++) if(t < nTimeBins) { nInState[cardType][t]+=prob; } else { return; } //Alternative solution would be to keep this count for each day instead of keeping it for each bin and aggregate in the end //to a count for each bin. //That would also work because we do not simulate two reviews of one card at one and the same day. } } private class PlottableSimulationResult { // Forecasted number of reviews // ArrayList: time // int[]: // 0 = Time // 1 = Learn // 2 = Young // 3 = Mature // 4 = Relearn private final ArrayList<int[]> nReviews; // Forecasted number of cards per state // First dimension: // 0 = Time // 4 = New // 3 = Young // 2 = Mature // 1 = Zeros (we can't say 'we know x relearn cards on day d') // Second dimension: time private final double[][] nInState; public PlottableSimulationResult(ArrayList<int[]> nReviews, double[][] nInState) { this.nReviews = nReviews; this.nInState = nInState; } public ArrayList<int[]> getNReviews() { return nReviews; } public double[][] getNInState() { return nInState; } } /** * A review has a particular outcome with a particular probability. * A review results in the state of the card (card interval) being changed. * A ReviewOutcome bundles the probability of the outcome and the card with changed state. */ private class ReviewOutcome { private Card card; private double prob; public ReviewOutcome(Card card, double prob) { this.card = card; this.prob = prob; } public void setAll(Card card, double prob) { this.card = card; this.prob = prob; } public Card getCard() { return card; } public double getProb() { return prob; } @Override public String toString() { return "ReviewOutcome{" + "card=" + card + ", prob=" + prob + '}'; } } /** * Bundles the information needed to simulate a review and the objects affected by the review. */ private class Review { /** * Deck-specific setting stored separately to save a method call on the deck object) */ private final int maxReviewsPerDay; /** * Number of reviews simulated for this card at time < tElapsed */ private int nPrevRevs; /** * The probability that the outcomes of the reviews simulated for this card at time < tElapsed are such that * this review [with this state of the card] will occur [at this time (tElapsed)]. */ private double prob; /** * The time instant at which the review takes place. */ private int tElapsed; /** * The outcome of the review. * We still have to do the review if the outcome has already been specified * (to update statistics, deterime probability of specified outcome, and to schedule subsequent reviews) * Only relevant if we are computing (all possible review outcomes), not if simulating (only one possible outcome) */ private int outcome; /** * Deck-specific settings */ private Deck deck; /** * State of the card before current review. * Needed to schedule current review but with different outcome and to update statistics. */ private Card card = new Card(0, 0, 0, 0, 0, 0); private Card prevCard = new Card(0, 0, 0, 0, 0, 0); /** * State of the card after current review. * Needed to schedule future review. */ private Card newCard = new Card(0, 0, 0, 0, 0, 0); /** * Statistics */ private final SimulationResult simulationResult; /** * Classifier which uses probability distribution from review log to predict outcome of review. */ private final EaseClassifier classifier; /** * Reviews which are scheduled to be simulated. * For adding current review with other outcome and future review. */ private final Stack<Review> reviews; /** * Review objects to be re-used so that we don't have to create new Review objects all the time. * Be careful: it also contains Review objects which are still in use. * So the algorithm using this list has to make sure that it only re-uses Review objects which are not in use anymore. */ private final List<Review> reviewList; /** * For creating future reviews which are to be scheduled as a result of the current review. * @see Review(Deck, SimulationResult, EaseClassifier, Stack<Review>) */ private Review (Review prevReview, Card card, int nPrevRevs, int tElapsed, double prob) { this.deck = prevReview.deck; this.card.setAll(card); this.simulationResult = prevReview.simulationResult; this.classifier = prevReview.classifier; this.reviews = prevReview.reviews; this.reviewList = prevReview.reviewList; this.nPrevRevs = nPrevRevs; this.tElapsed = tElapsed; this.prob = prob; this.maxReviewsPerDay = deck.getRevPerDay(); } /** * For creating a review which is to be scheduled. * After this constructor, either @see newCard(Card, NewCardSimulator) or existingCard(Card, int, int, double) has to be called. * @param deck Information needed to simulate a review: deck settings. * Will be affected by the review. After the review it will contain the card type etc. after the review. * @param simulationResult Will be affected by the review. After the review it will contain updated statistics. * @param classifier Information needed to simulate a review: transition probabilities to new card state for each possible current card state. * @param reviews Will be affected by the review. Scheduled future reviews of this card will be added. */ public Review(Deck deck, SimulationResult simulationResult, EaseClassifier classifier, Stack<Review> reviews, List<Review> reviewList) { this.deck = deck; this.simulationResult = simulationResult; this.classifier = classifier; this.reviews = reviews; this.reviewList = reviewList; this.maxReviewsPerDay = deck.getRevPerDay(); } /** * Re-use the current review object to schedule a new card. A new card here means that it has not been reviewed yet. * @param card Information needed to simulate a review: card due date, type and factor. * @param newCardSimulator Information needed to simulate a review: The next day new cards will be added and the number of cards already added on that day. * Will be affected by the review. After the review of a new card, the number of cards added on that day will be updated. * Next day new cards will be added might be updated if new card limit has been reached. */ public void newCard(Card card, NewCardSimulator newCardSimulator) { this.card = card; this.nPrevRevs = 0; this.prob = 1; this.outcome = 0; //# Rate-limit new cards by shifting starting time if (card.getType() == 0) tElapsed = newCardSimulator.simulateNewCard(deck); else tElapsed = card.getDue(); // Set state of card between start and first review // New reviews happen with probability 1 this.simulationResult.updateNInState(card, 0, tElapsed, 1); } /** * Re-use the current review object to schedule an existing card. An existing card here means that it has been reviewed before (either by the user or by the simulation) * and hence the due date is known. */ private void existingCard(Card card, int nPrevRevs, int tElapsed, double prob) { this.card.setAll(card); this.nPrevRevs = nPrevRevs; this.tElapsed = tElapsed; this.prob = prob; this.outcome = 0; } /** * Simulates one review of the card. The review results in: * - The card (prevCard and newCard) being updated * - New card simulator (when to schedule next new card) being updated if the card was new * - The simulationResult being updated. * - New review(s) being scheduled. */ public void simulateReview() { if(card.getType() == 0 || simulationResult.nReviewsDoneToday(tElapsed) < maxReviewsPerDay || outcome > 0) { // Update the forecasted number of reviews if(outcome == 0) simulationResult.incrementNReviews(card.getType(), tElapsed, prob); // Simulate response prevCard.setAll(card); newCard.setAll(card); ReviewOutcome reviewOutcome; if(tElapsed >= Settings.getComputeNDays() || prob < Settings.getComputeMaxError()) reviewOutcome = classifier.simSingleReview(newCard); else reviewOutcome = classifier.simSingleReview(newCard, outcome); //Timber.d("Simulation at t=" + tElapsed + ": outcome " + outcomeIdx + ": " + reviewOutcome.toString() ); newCard = reviewOutcome.getCard(); double outcomeProb = reviewOutcome.getProb(); //writeLog(newCard, outcomeProb); newCard.setLastReview(tElapsed); // If card failed, update "relearn" count if(newCard.getCorrect() == 0) simulationResult.incrementNReviews(3, tElapsed, prob * outcomeProb); // Set state of card between current and next review simulationResult.updateNInState(prevCard, newCard, tElapsed, tElapsed + newCard.getIvl(), prob * outcomeProb); // Schedule current review, but with other outcome if(outcomeProb < 1.0 && outcome < 3) scheduleCurrentReview(prevCard); // Advance time to next review scheduleNextReview(newCard, tElapsed + newCard.getIvl(), prob * outcomeProb); } else { // Advance time to next review (max. #reviews reached for this day) simulationResult.updateNInState(card, card, tElapsed, tElapsed + 1, prob); rescheduleCurrentReview(tElapsed + 1); } } private void writeLog(Card newCard, double outcomeProb) { String tabs = ""; for(int d = 0; d<nPrevRevs; d++) tabs += "\t"; Timber.d(tabs + "t=" + tElapsed + " p=" + prob + " * " + outcomeProb); Timber.d(tabs + prevCard); Timber.d(tabs + newCard); } /** * Schedule the current review at another time (will re-use current Review). */ private void rescheduleCurrentReview(int newTElapsed) { if (newTElapsed < simulationResult.getnDays()) { this.tElapsed = newTElapsed; this.reviews.push(this); } } /** * Schedule the current review at the current time, but with another outcome (will re-use current Review). * @param newCard */ private void scheduleCurrentReview(Card newCard) { this.card.setAll(newCard); this.outcome++; this.reviews.push(this); } /** * Schedule next review (will not re-use current Review). */ private void scheduleNextReview(Card newCard, int newTElapsed, double newProb) { //Schedule next review(s) if they are within the time window of the simulation if (newTElapsed < simulationResult.getnDays()) { Review review; //Re-use existing instance of the review object (to limit memory usage and prevent time taken by garbage collector) //This is possible since reviews with nPrevRevs > nPrevRevs of the current review which were already scheduled have all already been processed before we do the current review. if(reviewList.size() > nPrevRevs) { review = reviewList.get(nPrevRevs); review.existingCard(newCard, nPrevRevs + 1, newTElapsed, newProb); } else { if(reviewList.size() == nPrevRevs) { review = new Review(this, newCard, nPrevRevs + 1, newTElapsed, newProb); reviewList.add(review); } else { throw new IllegalStateException("State of previous reviews of this card should have been saved for determining possible future reviews other than the current one."); } } this.reviews.push(review); } } public int getT() { return tElapsed; } } }