Oh yeah, fuzz & load balancing. If I understand correctly load balancing is calculated by Anki into the initial intervals that the custom scheduling code then receives. So modifying those will lose load balancing.
Fuzz can be added.
Here’s a version that’s adds ±5% fuzz for raw intervals > 2.5. Also based on Jarret’s old FSRS helper code. With a deck setting useFuzz
to turn fuzz on or off. I also added a new deck param adjustIvlMax
for limiting how large intervals should be modified. I guess that should be set to something like 2-5 for the use case in this thread; depending on what intervals @dyzur thinks Good should not be Hard + 1.
Custom scheduling with (optional) fuzz & option to limit ivl adjustment
const LOG = false; // Set to true to enable logging for debugging
// Don't adjust intervals for new or new cards still in learning steps
if (
states.current.normal?.new ||
states.current.normal?.learning ||
states.current.filtered?.rescheduling?.originalState?.new ||
states.current.filtered?.rescheduling?.originalState?.learning
) {
return;
}
const deckParams = [
// Default parameters used in all decks that aren't skipped
// You don't need to define all parameters for each deck, only the ones you want to override
{
deckName: "global config for Custom Scheduler",
// desired retention will need to be copied from each deck's settings because the custom scheduler
// doesn't have access to the deck's settings
// whitelist mode: leave this undefined --> only decks in the deckParams list will be adjusted
// blacklist mode: set this to a value > 0.1 and < 0.99 --> all decks will be adjusted except
// those that you add to the skipDecks list below
desiredRetention: undefined,
// Decay parameter for the retention formula
// In FSRS-6 (Anki 25.05+) this will be the 21st (last) param in FSRS parameters in the deck
// settings. After that update, you'd need to copy that value for each deck to get the correct
// retention calculation.
// Leave undefined if on Anki <25.05 to use FSRS-4.5 formula
decayParam: undefined,
// Set this to a value > 1 to only adjust intervals less than that. This can be used to
// maintain load balancing that is applied by Anki into the default intervals.
// If left undefined, all intervals will be adjusted
adjustIvlMax: undefined,
// Set to false to disable fuzzing of intervals
useFuzz: true,
},
{
deckName: "Example Deck",
desiredRetention: 0.9,
adjustIvlMax: 2, // Only adjust intervals less than 2 days
},
];
// To not use custom scheduling in specific decks, fill them into the skip_decks list below.
// Please don't remove it even if you don't need it.
const skipDecks = [];
function getDeckname() {
return (
ctx?.deckName || document.getElementById("deck")?.getAttribute("deckName")
);
}
const globalDeckParams = deckParams.find(
(deck) => deck.deckName === "global config for Custom Scheduler"
);
if (!globalDeckParams) {
if (displaySchedulerState) {
schedulerStatus.innerHTML +=
'<br><span style="color:red;">ERROR: Global config not found</span>';
}
}
let currentDeckParams = globalDeckParams;
let deckName = getDeckname();
if (!deckName) {
} else {
if (skipDecks.some((skipDeck) => deckName.startsWith(skipDeck))) {
return;
}
// Arrange the deckParams of parent decks in front of their sub decks.
// This is so that we can define parameters for a parent deck and have them apply to all
// sub-decks while still being able to override them for specific sub-decks without
// having to define the same parameters for each sub-deck.
deckParams.sort(function (a, b) {
return a.deckName.localeCompare(b.deckName);
});
for (let i = 0; i < deckParams.length; i++) {
if (deckName.startsWith(deckParams[i]["deckName"])) {
foundParams = true;
currentDeckParams = {
...currentDeckParams,
...deckParams[i]
};
// continue looping and overwriting the parameters with the next matching sub-deck's
// parameters, if there are any
}
}
}
// Get params for current deck
const {
decayParam: DECK_DECAY,
desiredRetention: DECK_DR,
adjustIvlMax: DECK_MAX_ADJUST_IVL,
useFuzz: DECK_USE_FUZZ,
} = currentDeckParams;
if (!Number.isFinite(DECK_DR) || DECK_DR < 0.1 || DECK_DR > 0.99) {
// If desired retention is not set or invalid, do not adjust intervals
if (LOG) console.log("Invalid desired retention, skipping interval adjustment");
return;
}
if (!Number.isFinite(DECK_DECAY) || DECK_DECAY <= 0) {
// If decay parameter is not set or invalid, do not adjust intervals
if (LOG) console.log("Invalid decay parameter, skipping interval adjustment");
return;
}
const againRevObj =
states.again.normal?.review ||
states.again.normal?.relearning?.review ||
states.again.filtered?.rescheduling?.originalState?.review ||
states.again.filtered?.rescheduling?.originalState?.relearning?.review;
const baseAgainObj =
states.again.normal || states.again.filtered?.rescheduling?.originalState;
const againRelearnObj =
states.again.normal?.relearning?.learning ||
states.again.filtered?.rescheduling?.originalState?.relearning?.learning;
const hardRevObj =
states.hard.normal?.review ||
states.hard.filtered?.rescheduling?.originalState?.review ||
states.hard.normal?.relearning?.review ||
states.hard.filtered?.rescheduling?.originalState?.relearning?.review;
const baseHardObj =
states.hard.normal || states.hard.filtered?.rescheduling?.originalState;
const hardRelearnObj =
states.hard.normal?.relearning?.learning ||
states.hard.filtered?.rescheduling?.originalState?.relearning?.learning;
const goodRevObj =
states.good.normal?.review ||
states.good.filtered?.rescheduling?.originalState?.review ||
states.good.normal?.relearning?.review ||
states.good.filtered?.rescheduling?.originalState?.relearning?.review;
const goodRelearnObj =
states.good.normal?.relearning?.learning ||
states.good.filtered?.rescheduling?.originalState?.relearning?.learning;
const baseGoodObj =
states.good.normal || states.good.filtered?.rescheduling?.originalState;
const easyRevObj =
states.easy.normal?.review ||
states.easy.filtered?.rescheduling?.originalState?.review;
const baseEasyObj =
states.easy.normal || states.easy.filtered?.rescheduling?.originalState;
// If relearning steps are defined in the deck settings, we will use them, otherwise the interval
// modifications below would overwrite those
// When there are more than 1 relearning steps and you answer again, the next review will have
// the good answer be a relearning step
const previousReviewWasDeckDefinedRelearn = !!goodRelearnObj;
// If we are in a review, then deck defined relearning steps will be present in the again answer
const isReview = !goodRelearnObj && !!goodRevObj;
const isReviewButHasDeckDefinedRelearn = isReview && !!againRelearnObj;
if (previousReviewWasDeckDefinedRelearn || isReviewButHasDeckDefinedRelearn) {
if (LOG) console.log("relearning steps defined for deck, skipping interval modifications");
return;
}
// FSRS-4.5:
let FACTOR = 19 / 81;
let DECAY = -0.5;
if (Number.isFinite(decayParam)) {
// FSRS-5/6
FACTOR = 0.9 ** (-1 / decayParam);
DECAY = -decayParam;
}
/**
* Calculate the interval for a given stability and desired retention.
*
* @param {number} stability - Whole number, days
* @param {number} retention - Float, between 0 and 1
* @returns {number|undefined} - The calculated interval in days, potentially <1,
* or undefined if inputs are invalid.
*/
function calcIvlForRetention(stability, retention) {
if (!Number.isFinite(stability) || !Number.isFinite(retention) || retention <= 0.1) {
return undefined;
}
if (LOG) console.log("calcIvlForRetention", stability, retention);
// Return interval without rounding to an integer
return (stability / FACTOR) * (retention ** (1 / DECAY) - 1);
}
/**
* Add a relearning step to the base object if it doesn't already have one.
*
* @param {object} baseObj - The current answer object
* @param {object} revObj - The answer button object
* @param {number} remainingSteps - The number of remaining steps to set
* @param {number} ivlInDays - The interval in days (non-whole number) for the relearning step
* @param {object} [curRelearnObj] - The current relearning object, if any
*/
function addRelearnStep(baseObj, revObj, ivlInDays, remainingSteps, curRelearnObj) {
if (LOG) console.log("addRelearnStep, ivlInDays", ivlInDays);
const scheduledSecs = Math.ceil(ivlInDays * 24 * 60 * 60);
// Make relearning object to add to the base object
const relearnObj = {
scheduledSecs,
elapsedSecs: 0,
remainingSteps,
memoryState: curRelearnObj?.memoryState
? curRelearnObj.memoryState
: revObj.memoryState
};
// add .relearning to the baseObj and move revObj to it
baseObj.relearning = {
review: revObj,
learning: relearnObj
};
// Remove the review from the baseObj, making this answer a relearning step
delete baseObj.review;
}
// Global fuzz factor for all ratings.
const fuzzFactor = setFuzzFactor();
if (LOG) console.log("fuzzFactor", fuzzFactor);
function applyFuzz(ivl) {
if (!DECK_USE_FUZZ) return Math.round(ivl);
if (ivl < 2.5) return ivl;
ivl = Math.round(ivl);
let min_ivl = Math.max(2, Math.round(ivl * 0.95 - 1));
let max_ivl = Math.round(ivl * 1.05 + 1);
return Math.floor(fuzzFactor * (max_ivl - min_ivl + 1) + min_ivl);
}
function get_seed() {
if (!customData.again.s | !customData.hard.s | !customData.good.s | !customData.easy.s) {
if (typeof ctx !== 'undefined' && ctx.seed) {
return ctx.seed;
} else {
return document.getElementById("qa").innerText;
}
} else {
return customData.good.s;
}
}
function setFuzzFactor() {
// Note: Originally copied from seedrandom.js package (https://github.com/davidbau/seedrandom)
!function (f, a, c) { var s, l = 256, p = "random", d = c.pow(l, 6), g = c.pow(2, 52), y = 2 * g, h = l - 1; function n(n, t, r) { function e() { for (var n = u.g(6), t = d, r = 0; n < g;)n = (n + r) * l, t *= l, r = u.g(1); for (; y <= n;)n /= 2, t /= 2, r >>>= 1; return (n + r) / t } var o = [], i = j(function n(t, r) { var e, o = [], i = typeof t; if (r && "object" == i) for (e in t) try { o.push(n(t[e], r - 1)) } catch (n) { } return o.length ? o : "string" == i ? t : t + "\0" }((t = 1 == t ? { entropy: !0 } : t || {}).entropy ? [n, S(a)] : null == n ? function () { try { var n; return s && (n = s.randomBytes) ? n = n(l) : (n = new Uint8Array(l), (f.crypto || f.msCrypto).getRandomValues(n)), S(n) } catch (n) { var t = f.navigator, r = t && t.plugins; return [+new Date, f, r, f.screen, S(a)] } }() : n, 3), o), u = new m(o); return e.int32 = function () { return 0 | u.g(4) }, e.quick = function () { return u.g(4) / 4294967296 }, e.double = e, j(S(u.S), a), (t.pass || r || function (n, t, r, e) { return e && (e.S && v(e, u), n.state = function () { return v(u, {}) }), r ? (c[p] = n, t) : n })(e, i, "global" in t ? t.global : this == c, t.state) } function m(n) { var t, r = n.length, u = this, e = 0, o = u.i = u.j = 0, i = u.S = []; for (r || (n = [r++]); e < l;)i[e] = e++; for (e = 0; e < l; e++)i[e] = i[o = h & o + n[e % r] + (t = i[e])], i[o] = t; (u.g = function (n) { for (var t, r = 0, e = u.i, o = u.j, i = u.S; n--;)t = i[e = h & e + 1], r = r * l + i[h & (i[e] = i[o = h & o + t]) + (i[o] = t)]; return u.i = e, u.j = o, r })(l) } function v(n, t) { return t.i = n.i, t.j = n.j, t.S = n.S.slice(), t } function j(n, t) { for (var r, e = n + "", o = 0; o < e.length;)t[h & o] = h & (r ^= 19 * t[h & o]) + e.charCodeAt(o++); return S(t) } function S(n) { return String.fromCharCode.apply(0, n) } if (j(c.random(), a), "object" == typeof module && module.exports) { module.exports = n; try { s = require("crypto") } catch (n) { } } else "function" == typeof define && define.amd ? define(function () { return n }) : c["seed" + p] = n }("undefined" != typeof self ? self : this, [], Math);
// MIT License
// Copyright 2019 David Bau.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
let seed = get_seed();
const generator = new Math.seedrandom(seed);
const fuzz_factor = generator();
seed = Math.round(fuzz_factor * 10000);
customData.again.s = (seed + 1) % 10000;
customData.hard.s = (seed + 2) % 10000;
customData.good.s = (seed + 3) % 10000;
customData.easy.s = (seed + 4) % 10000;
return fuzz_factor;
}
/**
* Set the interval for review based on the stability and desired retention.
*
* @param {object} baseObj - The current answer object
* @param {object} revObj - The answer button object
* @param {object} relearnObj - The relearning object, if any
* @param {number} remainingSteps - The number of remaining steps to set
* @param {number} [minIvlInDays=1] - Integer, the minimum interval in days for the review.
* Only applied when the calculated interval is >= 1
**/
function setIntervalForReview(baseObj, revObj, relearnObj, remainingSteps, minIvlInDays = 1) {
// Sanity check, do nothing if args are not valid
if (typeof baseObj !== "object"
|| !baseObj
|| typeof revObj !== "object"
|| !revObj
|| !Number.isFinite(remainingSteps)
|| remainingSteps < 0
|| (relearnObj && typeof relearnObj !== "object")
|| !Number.isFinite(minIvlInDays)
|| minIvlInDays < 1) {
return;
}
// Check revObj has a memoryState object; FSRS should be enabled
if (!revObj.memoryState || !Number.isFinite(revObj.memoryState.stability)) {
if (LOG) console.log("setIntervalForReview: revObj does not have a memoryState object, skipping");
return;
}
const curIvl = revObj.scheduledDays;
if (Number.isFinite(DECK_MAX_ADJUST_IVL) && curIvl >= DECK_MAX_ADJUST_IVL) {
if (LOG) console.log("setIntervalForReview: current interval is greater than or equal to adjustIvlMax, skipping");
return;
}
// Calculate the interval in days based on the stability and desired retention
const ivlInDays = calcIvlForRetention(revObj.memoryState.stability, DECK_DR);
if (!Number.isFinite(ivlInDays) || ivlInDays < 0) {
if (LOG) console.log("setIntervalForReview: invalid ivlInDays, skipping");
return;
}
if (LOG) console.log("setIntervalForReview: ivlInDays", ivlInDays);
// If the interval is less than 1 day, we need to add a relearning step
if (ivlInDays < 1) {
addRelearnStep(baseObj, revObj, ivlInDays, remainingSteps, relearnObj);
// Anki's default behavior is to to always round up making Retrievability always < DR when
// a card is due for review
// We'll instead set the scheduledDays to the rounded interval
// By rounding, we set it closer to the expected retention but this will cause the
// Retrivievabilty to be a bit higher than the target retention when we round down, the
// difference being largest when intervals are small
revObj.scheduledDays = Math.max(applyFuzz(ivlInDays), minIvlInDays);
if (LOG) console.log("setIntervalForReview: scheduledDays", revObj.scheduledDays);
}
}
// If hard interval is 1 day, by default good interval will be at least 2 days
// In such cases the good interval may occur at a time when expected retention is much lower
// than the target retention
// To fix this we will calculate the good & easy intervals based on the desired retention
// only if the hard interval is 1 day (indicating we are at low stability)
if (LOG) console.log("setting interval for again, before edit", JSON.stringify(baseAgainObj, null, 2));
setIntervalForReview(baseAgainObj, againRevObj, againRelearnObj, 1);
if (LOG) console.log("again, after edit", JSON.stringify(baseAgainObj, null, 2));
if (LOG) console.log("setting interval for hard, before edit", JSON.stringify(baseHardObj, null, 2));
setIntervalForReview(baseHardObj, hardRevObj, hardRelearnObj, 1);
if (LOG) console.log("hard, after edit", JSON.stringify(baseHardObj, null, 2));
if (LOG) console.log("setting interval for good, before edit", JSON.stringify(baseGoodObj, null, 2));
setIntervalForReview(baseGoodObj, goodRevObj, null, 0);
if (LOG) console.log("good, after edit", JSON.stringify(baseGoodObj, null, 2));
// Set easy interval to good + 1, as it doesn't make sense to have both good and easy be the same
// This is only done when the modified good interval was recalculated as at least 1 day.
// If the good interval is less than 1 day, the easy interval will either be less than 1 day
// too but still greater than the good interval, or it will be >= 1 day
const minEasyIvl = Number.isFinite(baseGoodObj.review?.scheduledDays)
? baseGoodObj.review.scheduledDays + 1
: undefined;
if (LOG) console.log("setting interval for easy", JSON.stringify(baseEasyObj, null, 2));
setIntervalForReview(baseEasyObj, easyRevObj, null, 0, minEasyIvl);
if (LOG) console.log("easy, after edit", JSON.stringify(baseEasyObj, null, 2));