Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

load balancer! #3230

Merged
merged 65 commits into from
Aug 17, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
b1836ed
start of load balancer
jakeprobst Jun 10, 2024
5bb8959
add configuration options; option to load balance per deck
jakeprobst Jun 11, 2024
ac12e13
formatting
jakeprobst Jun 11, 2024
9c96fe5
clippy
jakeprobst Jun 11, 2024
25a8549
add myself to contributors
jakeprobst Jun 11, 2024
cd5e0df
cleanup
jakeprobst Jun 11, 2024
9595f6c
cargo fmt
jakeprobst Jun 11, 2024
63fa18b
copyright header on load_balancer.rs
jakeprobst Jun 11, 2024
a2bad0a
remove extra space
jakeprobst Jun 11, 2024
d53e248
more formatting
jakeprobst Jun 11, 2024
be92048
python formatting
jakeprobst Jun 11, 2024
8e28b71
ignore this being None
jakeprobst Jun 11, 2024
370eef6
only calculate notes on each day if we are trying to avoid siblings
jakeprobst Jun 13, 2024
cc3e991
don't fuzz intervals if the load balancer is enabled
jakeprobst Jun 13, 2024
98b3e79
force generator to eval so this actually happens
jakeprobst Jun 14, 2024
4972ef0
load balance instead of fuzzing, rather than in addition to
jakeprobst Jun 14, 2024
672021d
use builtin fuzz_bounds rather than reinvent something new
jakeprobst Jun 14, 2024
901bec7
print some debug info on how its load balancing
jakeprobst Jun 14, 2024
d16d4e3
clippy
jakeprobst Jun 14, 2024
e3a2d85
more accurately load balance only when we want to fuzz
jakeprobst Jun 14, 2024
7ff398a
incorrectly doublechecking the presence of the load balancer
jakeprobst Jun 14, 2024
47feb8e
more printfs for debugging
jakeprobst Jun 14, 2024
e80f90d
avoid siblings -> disperse siblings
jakeprobst Jun 14, 2024
ec6e9c0
load balance learning graduating intervals
jakeprobst Jun 25, 2024
a539954
load balancer: respect min/max intervals; graduating easy should be a…
jakeprobst Jun 27, 2024
b02cf01
filter out after-days under minimum interval
jakeprobst Jun 27, 2024
31691ed
this is an inclusive check
jakeprobst Jun 27, 2024
54521fb
switch load balancer to caching instead of on the fly calculation
jakeprobst Jul 12, 2024
267e255
Merge branch 'main' into load_balancer
jakeprobst Jul 12, 2024
7a6b42c
handle case where load balancer would balance outside of its bounds
jakeprobst Jul 12, 2024
8b93c39
disable lb when unselecting it in preferences
jakeprobst Jul 12, 2024
f3fd530
call load_balancer in StateContext::with_review_fuzz instead of next to
jakeprobst Jul 12, 2024
daeb154
rebuild load balancer when card queue is rebuilt
jakeprobst Jul 13, 2024
f9a0073
remove now-unused configuration options
jakeprobst Jul 13, 2024
17569e7
add note option to notetype to enable/disable sibling dispersion
jakeprobst Jul 13, 2024
d52afcd
add options to exclude decks from load balancing
jakeprobst Jul 14, 2024
de017e1
theres a lint checking that the link actually exists so I guess I'll …
jakeprobst Jul 14, 2024
033d757
Merge branch 'main' into load_balancer
jakeprobst Jul 22, 2024
4081f22
how did I even update this
jakeprobst Jul 22, 2024
291d8d1
move load balancer to cardqueue
jakeprobst Jul 23, 2024
7441395
remove per-deck balancing options
jakeprobst Jul 24, 2024
3395349
improve determining whether to disperse siblings when load balancing
jakeprobst Jul 24, 2024
580ea5e
don't recalculate notes on days every time
jakeprobst Jul 24, 2024
4ae66ae
Merge branch 'main' into load_balancer
dae Aug 5, 2024
ed97e8d
remove debug code
jakeprobst Aug 5, 2024
aff9087
remove all configuration; load balancer enabled by default; disperse …
jakeprobst Aug 5, 2024
6b8d9f8
didn't fully remove caring about decks from load balancer sql query
jakeprobst Aug 5, 2024
50e99fe
load balancer should only count cards in the same preset
jakeprobst Aug 5, 2024
0281aff
fuzz interval if its outside of load balancer's range
jakeprobst Aug 5, 2024
6a2b5b9
also check minimum when bailing out of load balancer
jakeprobst Aug 5, 2024
b7d0e59
cleanup; make tests happy
jakeprobst Aug 5, 2024
a3104ba
experimental weight-based load balance fuzzing
jakeprobst Aug 8, 2024
b4207e2
take into account interval when weighting as it seems to help
jakeprobst Aug 10, 2024
bcd56a1
if theres no cards the interval weight is just 1.0
jakeprobst Aug 13, 2024
72f9b69
make load balancer disableable through debug console
jakeprobst Aug 14, 2024
73f38d4
remove debug prints
jakeprobst Aug 14, 2024
bcfcfc9
typo
jakeprobst Aug 14, 2024
1d8882a
remove debugging print
jakeprobst Aug 17, 2024
f98210f
explain a bit how load balancer works
jakeprobst Aug 17, 2024
ad6d242
properly balance per preset
jakeprobst Aug 17, 2024
af45ea3
use inclusive range rather than +1
jakeprobst Aug 17, 2024
9e13e10
Merge remote-tracking branch 'upstream/main' into load_balancer
jakeprobst Aug 17, 2024
2742d75
-1 type cast
jakeprobst Aug 17, 2024
32840e1
move type hint somewhere less ugly; fix comment typo
jakeprobst Aug 17, 2024
edbe52c
Reuse existing deck list from parent function
dae Aug 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ Wu Yi-Wei <https://github.com/Ianwu0812>
RRomeroJr <[email protected]>
Xidorn Quan <[email protected]>
Alexander Bocken <[email protected]>
jake <[email protected]>

********************

Expand Down
4 changes: 4 additions & 0 deletions ftl/core/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ preferences-network-timeout = Network timeout
preferences-reset-window-sizes = Reset Window Sizes
preferences-reset-window-sizes-complete = Window sizes and locations have been reset.
preferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable.
preferences-load-balancer = Load Balancer
preferences-load-balancer-enable = Enable Load Balancer
preferences-load-balancer-avoid-siblings = Avoid Siblings
preferences-load-balancer-per-deck = Load balance each deck individually

## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.

Expand Down
6 changes: 6 additions & 0 deletions proto/anki/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ message ConfigKey {
RANDOM_ORDER_REPOSITION = 23;
SHIFT_POSITION_OF_EXISTING_CARDS = 24;
RENDER_LATEX = 25;
LOAD_BALANCER_ENABLE = 26;
LOAD_BALANCER_AVOID_SIBLINGS = 27;
LOAD_BALANCER_PER_DECK = 28;
}
enum String {
SET_DUE_BROWSER = 0;
Expand Down Expand Up @@ -115,6 +118,9 @@ message Preferences {
bool show_remaining_due_counts = 3;
bool show_intervals_on_buttons = 4;
uint32 time_limit_secs = 5;
bool load_balancer_enable = 6;
bool load_balancer_avoid_siblings = 7;
bool load_balancer_per_deck = 8;
}
message Editing {
bool adding_defaults_to_current_deck = 1;
Expand Down
36 changes: 36 additions & 0 deletions qt/aqt/forms/preferences.ui
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,42 @@
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>preferences_load_balancer</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QCheckBox" name="load_balancer_enable">
<property name="text">
<string>preferences_load_balancer_enable</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="load_balancer_avoid_siblings">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>preferences_load_balancer_avoid_siblings</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="load_balancer_per_deck">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>preferences_load_balancer_per_deck</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_12">
<property name="orientation">
Expand Down
23 changes: 23 additions & 0 deletions qt/aqt/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ def setup_collection(self) -> None:
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
form.load_balancer_enable.setChecked(reviewing.load_balancer_enable)
form.load_balancer_avoid_siblings.setChecked(
reviewing.load_balancer_avoid_siblings
)
form.load_balancer_per_deck.setChecked(reviewing.load_balancer_per_deck)

form.load_balancer_enable.stateChanged.connect(
lambda: (
cb.setEnabled(form.load_balancer_enable.isChecked()) # type: ignore
for cb in [
form.load_balancer_avoid_siblings,
form.load_balancer_per_deck,
]
)
)
if reviewing.load_balancer_enable:
form.load_balancer_avoid_siblings.setEnabled(True)
form.load_balancer_per_deck.setEnabled(True)

editing = self.prefs.editing
form.useCurrent.setCurrentIndex(
Expand Down Expand Up @@ -150,6 +168,11 @@ def update_collection(self, on_done: Callable[[], None]) -> None:
reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
reviewing.load_balancer_enable = self.form.load_balancer_enable.isChecked()
reviewing.load_balancer_avoid_siblings = (
self.form.load_balancer_avoid_siblings.isChecked()
)
reviewing.load_balancer_per_deck = self.form.load_balancer_per_deck.isChecked()

editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
Expand Down
3 changes: 3 additions & 0 deletions rslib/src/backend/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ impl From<BoolKeyProto> for BoolKey {
BoolKeyProto::RandomOrderReposition => BoolKey::RandomOrderReposition,
BoolKeyProto::ShiftPositionOfExistingCards => BoolKey::ShiftPositionOfExistingCards,
BoolKeyProto::RenderLatex => BoolKey::RenderLatex,
BoolKeyProto::LoadBalancerEnable => BoolKey::LoadBalancerEnable,
BoolKeyProto::LoadBalancerAvoidSiblings => BoolKey::LoadBalancerAvoidSiblings,
BoolKeyProto::LoadBalancerPerDeck => BoolKey::LoadBalancerPerDeck,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions rslib/src/config/bool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ pub enum BoolKey {
WithScheduling,
WithDeckConfigs,
Fsrs,
LoadBalancerEnable,
LoadBalancerAvoidSiblings,
LoadBalancerPerDeck,
#[strum(to_string = "normalize_note_text")]
NormalizeNoteText,
#[strum(to_string = "dayLearnFirst")]
Expand Down
9 changes: 9 additions & 0 deletions rslib/src/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ impl Collection {
show_intervals_on_buttons: self
.get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons),
time_limit_secs: self.get_answer_time_limit_secs(),
load_balancer_enable: self.get_config_bool(BoolKey::LoadBalancerEnable),
load_balancer_avoid_siblings: self.get_config_bool(BoolKey::LoadBalancerAvoidSiblings),
load_balancer_per_deck: self.get_config_bool(BoolKey::LoadBalancerPerDeck),
})
}

Expand All @@ -117,6 +120,12 @@ impl Collection {
s.show_intervals_on_buttons,
)?;
self.set_answer_time_limit_secs(s.time_limit_secs)?;
self.set_config_bool_inner(BoolKey::LoadBalancerEnable, s.load_balancer_enable)?;
self.set_config_bool_inner(
BoolKey::LoadBalancerAvoidSiblings,
s.load_balancer_avoid_siblings,
)?;
self.set_config_bool_inner(BoolKey::LoadBalancerPerDeck, s.load_balancer_per_deck)?;
Ok(())
}

Expand Down
34 changes: 32 additions & 2 deletions rslib/src/scheduler/answering/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use revlog::RevlogEntryPartial;

use super::fsrs::weights::ignore_revlogs_before_ms_from_config;
use super::queue::BuryMode;
use super::states::load_balancer::LoadBalancer;
use super::states::steps::LearningSteps;
use super::states::CardState;
use super::states::FilteredState;
Expand All @@ -26,6 +27,7 @@ use super::timespan::answer_button_time_collapsible;
use super::timing::SchedTimingToday;
use crate::card::CardQueue;
use crate::card::CardType;
use crate::config::BoolKey;
use crate::deckconfig::DeckConfig;
use crate::deckconfig::LeechAction;
use crate::decks::Deck;
Expand Down Expand Up @@ -77,7 +79,10 @@ impl CardStateUpdater {
/// Returns information required when transitioning from one card state to
/// another with `next_states()`. This separate structure decouples the
/// state handling code from the rest of the Anki codebase.
pub(crate) fn state_context(&self) -> StateContext<'_> {
pub(crate) fn state_context<'a>(
&'a self,
load_balancer: Option<LoadBalancer<'a>>,
) -> StateContext<'a> {
StateContext {
fuzz_factor: get_fuzz_factor(self.fuzz_seed),
steps: self.learn_steps(),
Expand All @@ -89,6 +94,7 @@ impl CardStateUpdater {
interval_multiplier: self.config.inner.interval_multiplier,
maximum_review_interval: self.config.inner.maximum_review_interval,
leech_threshold: self.config.inner.leech_threshold,
load_balancer,
relearn_steps: self.relearn_steps(),
lapse_multiplier: self.config.inner.lapse_multiplier,
minimum_lapse_interval: self.config.inner.minimum_lapse_interval,
Expand Down Expand Up @@ -215,9 +221,33 @@ impl Collection {
/// Return the next states that will be applied for each answer button.
pub fn get_scheduling_states(&mut self, cid: CardId) -> Result<SchedulingStates> {
let card = self.storage.get_card(cid)?.or_not_found(cid)?;
let deck_id = card.deck_id;
let note_id = card.note_id;
let ctx = self.card_state_updater(card)?;
let current = ctx.current_card_state();
let state_ctx = ctx.state_context();
let today = self.timing_today()?.days_elapsed;

let load_balancer = if self.get_config_bool(BoolKey::LoadBalancerEnable) {
if self.get_config_bool(BoolKey::LoadBalancerPerDeck) {
Some(LoadBalancer::new_from_deck(
today,
&self.storage,
note_id,
deck_id,
self.get_config_bool(BoolKey::LoadBalancerAvoidSiblings),
))
} else {
Some(LoadBalancer::new_from_collection(
today,
&self.storage,
note_id,
self.get_config_bool(BoolKey::LoadBalancerAvoidSiblings),
))
}
} else {
None
};
let state_ctx = ctx.state_context(load_balancer);
Ok(current.next_states(&state_ctx))
}

Expand Down
136 changes: 136 additions & 0 deletions rslib/src/scheduler/states/load_balancer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

use std::cmp::Ordering;
use std::collections::HashSet;

use crate::decks::DeckId;
use crate::notes::NoteId;
use crate::storage::SqliteStorage;

const MAX_LOAD_BALANCE_INTERVAL: u32 = 90;
const PERCENT_BEFORE: f32 = 0.1;
const PERCENT_AFTER: f32 = 0.1;
const DAYS_MIN_BEFORE: i32 = 1;
const DAYS_MIN_AFTER: i32 = 1;
const DAYS_MAX_BEFORE: i32 = 6;
const DAYS_MAX_AFTER: i32 = 4;

pub struct LoadBalancer<'a> {
today: u32,
note_id: NoteId,
deck_id: Option<DeckId>,
avoid_siblings: bool,
storage: &'a SqliteStorage,
}

impl<'a> LoadBalancer<'a> {
pub fn new_from_collection(
today: u32,
storage: &'a SqliteStorage,
note_id: NoteId,
avoid_siblings: bool,
) -> LoadBalancer<'a> {
LoadBalancer {
today,
note_id,
avoid_siblings,
storage,
deck_id: None,
}
}

pub fn new_from_deck(
today: u32,
storage: &'a SqliteStorage,
note_id: NoteId,
deck_id: DeckId,
avoid_siblings: bool,
) -> LoadBalancer<'a> {
LoadBalancer {
today,
note_id,
avoid_siblings,
storage,
deck_id: Some(deck_id),
}
}

pub fn find_interval(&self, interval: u32) -> u32 {
// if we're sending a card far out into the future, the need to balance is low
if interval > MAX_LOAD_BALANCE_INTERVAL {
return interval;
}

// determine the range of days to check
let before_range =
((interval as f32 * PERCENT_BEFORE) as i32).clamp(DAYS_MIN_BEFORE, DAYS_MAX_BEFORE);
let after_range =
((interval as f32 * PERCENT_AFTER) as i32).clamp(DAYS_MIN_AFTER, DAYS_MAX_AFTER);
jakeprobst marked this conversation as resolved.
Show resolved Hide resolved

let before_days = (interval as i32 - before_range).max(1);
let after_days = interval as i32 + after_range + 1; // +1 to make the range inclusive of the actual value

// ok this looks weird but its a totally reasonable thing
// I want to be as close to the original interval as possible
// so this enumerates out from the center
// i.e. 0 -1 1 -2 2 .....
// for optimal load balancing, it might be preferable to
// just default to the earliest date? it is how the old
// addon used to do it...
let intervals_to_check = (before_days..interval as i32)
.map(|before| before - interval as i32)
.chain((interval as i32..after_days).map(|after| after - interval as i32))
.enumerate()
.collect::<Vec<_>>();
jakeprobst marked this conversation as resolved.
Show resolved Hide resolved

let cards = if let Some(deck_id) = self.deck_id {
self.storage
.get_cards_in_deck_due_in_range(
self.today + before_days as u32,
self.today + after_days as u32,
deck_id,
)
.unwrap()
} else {
self.storage
.get_all_cards_due_in_range(
self.today + before_days as u32,
self.today + after_days as u32,
)
.unwrap()
};

// table to look up if there are siblings for a card on a day
let notes = cards
.iter()
.map(|cards| cards.iter().map(|card| card.1).collect::<HashSet<_>>())
.collect::<Vec<_>>();

dae marked this conversation as resolved.
Show resolved Hide resolved
// find the day with fewest number of cards, falling back to distance from the
// initial interval
dae marked this conversation as resolved.
Show resolved Hide resolved
let interval_modifier = intervals_to_check
.into_iter()
.min_by(|a, b| {
let a_len = cards[a.0].len();
let b_len = cards[b.0].len();

let a_has_sibling = notes[a.0].contains(&self.note_id);
let b_has_sibling = notes[b.0].contains(&self.note_id);

if self.avoid_siblings && a_has_sibling != b_has_sibling {
return a_has_sibling.cmp(&b_has_sibling);
}

match a_len.cmp(&b_len) {
Ordering::Greater => Ordering::Greater,
Ordering::Less => Ordering::Less,
Ordering::Equal => a.1.abs().cmp(&b.1.abs()),
}
})
.map(|interval| interval.1)
.unwrap_or(0);

(interval as i32 + interval_modifier) as u32
}
}
Loading