Feat/Easy Days (#3442)

* Feat/Easy Days

* format

* add easy_days_percentages to deck_config

* configure Easy Days via table

* remove unused code

* add translatable strings & add default of easy days

* don't check easy_days_percentages when deserialize

* pass test::all_reserved_fields_are_removed

* consider next_day_at when interval_to_weekday

* remove y-axis-title created in last simulation

* EstimatedTotalKnowledge should be integer

* Reorder deck option sections (dae)

- Move FSRS to bottom left, to move it closer to the top, and so
the left and right columns appear roughly balanced when FSRS is
enabled.
- Move Easy Days above Advanced

* Don't crash if wrong number of days (dae)

* Use lower field number (dae)

Repeated fields are more compactly stored in the first 15 fields.
This commit is contained in:
Jarrett Ye 2024-10-11 17:47:44 +08:00 committed by GitHub
parent 79a6a4dc53
commit a982720a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 210 additions and 8 deletions

View file

@ -277,6 +277,19 @@ deck-config-new-interval-tooltip = The multiplier applied to a review interval w
deck-config-minimum-interval-tooltip = The minimum interval given to a review card after answering `Again`.
deck-config-custom-scheduling = Custom scheduling
deck-config-custom-scheduling-tooltip = Affects the entire collection. Use at your own risk!
# Easy Days section
deck-config-easy-days-title = Easy Days
deck-config-easy-days-monday = Monday
deck-config-easy-days-tuesday = Tuesday
deck-config-easy-days-wednesday = Wednesday
deck-config-easy-days-thursday = Thursday
deck-config-easy-days-friday = Friday
deck-config-easy-days-saturday = Saturday
deck-config-easy-days-sunday = Sunday
deck-config-easy-days-normal = Normal
deck-config-easy-days-reduced = Reduced
deck-config-easy-days-minimum = Minimum
## Adding/renaming

View file

@ -109,7 +109,7 @@ message DeckConfig {
repeated float fsrs_weights = 3;
reserved 4 to 8;
reserved 5 to 8;
uint32 new_per_day = 9;
uint32 reviews_per_day = 10;
@ -159,6 +159,7 @@ message DeckConfig {
// for fsrs
float desired_retention = 37;
string ignore_revlogs_before_date = 46;
repeated float easy_days_percentages = 4;
// used for fsrs_reschedule in the past
reserved 39;
float historical_retention = 40;

View file

@ -80,6 +80,7 @@ const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner {
historical_retention: 0.9,
weight_search: String::new(),
ignore_revlogs_before_date: String::new(),
easy_days_percentages: Vec::new(),
};
impl Default for DeckConfig {
@ -92,6 +93,7 @@ impl Default for DeckConfig {
inner: DeckConfigInner {
learn_steps: vec![1.0, 10.0],
relearn_steps: vec![10.0],
easy_days_percentages: vec![1.0; 7],
..DEFAULT_DECK_CONFIG_INNER
},
}

View file

@ -76,6 +76,8 @@ pub struct DeckConfSchema11 {
#[serde(default)]
ignore_revlogs_before_date: String,
#[serde(default)]
easy_days_percentages: Vec<f32>,
#[serde(default)]
stop_timer_on_answer: bool,
#[serde(default)]
seconds_to_show_question: f32,
@ -309,6 +311,7 @@ impl Default for DeckConfSchema11 {
sm2_retention: 0.9,
weight_search: "".to_string(),
ignore_revlogs_before_date: "".to_string(),
easy_days_percentages: vec![1.0; 7],
}
}
}
@ -385,6 +388,7 @@ impl From<DeckConfSchema11> for DeckConfig {
bury_interday_learning: c.bury_interday_learning,
fsrs_weights: c.fsrs_weights,
ignore_revlogs_before_date: c.ignore_revlogs_before_date,
easy_days_percentages: c.easy_days_percentages,
desired_retention: c.desired_retention,
historical_retention: c.sm2_retention,
weight_search: c.weight_search,
@ -499,6 +503,7 @@ impl From<DeckConfig> for DeckConfSchema11 {
sm2_retention: i.historical_retention,
weight_search: i.weight_search,
ignore_revlogs_before_date: i.ignore_revlogs_before_date,
easy_days_percentages: i.easy_days_percentages,
}
}
}
@ -531,6 +536,7 @@ static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! {
"sm2Retention",
"weightSearch",
"ignoreRevlogsBeforeDate",
"easyDaysPercentages",
};
static RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! {

View file

@ -150,7 +150,12 @@ impl QueueBuilder {
.values()
.filter_map(|deck| Some((deck.id, deck.config_id()?)))
.collect::<HashMap<_, _>>();
let load_balancer = LoadBalancer::new(timing.days_elapsed, did_to_dcid, &col.storage)?;
let load_balancer = LoadBalancer::new(
timing.days_elapsed,
did_to_dcid,
col.timing_today()?.next_day_at,
&col.storage,
)?;
Ok(QueueBuilder {
new: Vec::new(),

View file

@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::collections::HashSet;
use chrono::Datelike;
use rand::distributions::Distribution;
use rand::distributions::WeightedIndex;
use rand::rngs::StdRng;
@ -12,6 +13,7 @@ use rand::SeedableRng;
use super::fuzz::constrained_fuzz_bounds;
use crate::card::CardId;
use crate::deckconfig::DeckConfigId;
use crate::error::InvalidInputError;
use crate::notes::NoteId;
use crate::prelude::*;
use crate::storage::SqliteStorage;
@ -82,12 +84,15 @@ pub struct LoadBalancer {
/// Load balancer operates at the preset level, it only counts
/// cards in the same preset as the card being balanced.
days_by_preset: HashMap<DeckConfigId, [LoadBalancerDay; LOAD_BALANCE_DAYS]>,
easy_days_percentages_by_preset: HashMap<DeckConfigId, [f32; 7]>,
next_day_at: TimestampSecs,
}
impl LoadBalancer {
pub fn new(
today: u32,
did_to_dcid: HashMap<DeckId, DeckConfigId>,
next_day_at: TimestampSecs,
storage: &SqliteStorage,
) -> Result<LoadBalancer> {
let cards_on_each_day =
@ -126,8 +131,29 @@ impl LoadBalancer {
deckconfig_group
},
);
let configs = storage.get_deck_config_map()?;
Ok(LoadBalancer { days_by_preset })
let mut easy_days_percentages_by_preset = HashMap::with_capacity(configs.len());
for (dcid, conf) in configs {
let easy_days_percentages = if conf.inner.easy_days_percentages.is_empty() {
[1.0; 7]
} else {
conf.inner.easy_days_percentages.try_into().map_err(|_| {
AnkiError::from(InvalidInputError {
message: "expected 7 days".into(),
source: None,
backtrace: None,
})
})?
};
easy_days_percentages_by_preset.insert(dcid, easy_days_percentages);
}
Ok(LoadBalancer {
days_by_preset,
easy_days_percentages_by_preset,
next_day_at,
})
}
pub fn review_context(
@ -182,6 +208,24 @@ impl LoadBalancer {
let days = self.days_by_preset.get(&deckconfig_id)?;
let interval_days = &days[before_days as usize..=after_days as usize];
// calculate review counts and expected distribution
let (review_counts, weekdays): (Vec<usize>, Vec<usize>) = interval_days
.iter()
.enumerate()
.map(|(i, day)| {
(
day.cards.len(),
interval_to_weekday(i as u32 + before_days, self.next_day_at),
)
})
.unzip();
let easy_days_percentages = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;
let percentages = weekdays
.iter()
.map(|&wd| easy_days_percentages[wd])
.collect::<Vec<_>>();
let expected_distribution = check_review_distribution(&review_counts, &percentages);
// calculate weights for each day
let intervals_and_weights = interval_days
.iter()
@ -198,7 +242,7 @@ impl LoadBalancer {
})
.unwrap_or(1.0);
let weight = match interval_day.cards.len() {
let weight = match review_counts[interval_index] {
0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight
card_count => {
let card_count_weight = (1.0 / card_count as f32).powi(2);
@ -208,7 +252,10 @@ impl LoadBalancer {
}
};
(target_interval, weight)
(
target_interval,
weight * expected_distribution[interval_index],
)
})
.collect::<Vec<_>>();
@ -237,3 +284,27 @@ impl LoadBalancer {
}
}
}
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
let target_datetime = next_day_at
.adding_secs((interval - 1) as i64 * 86400)
.local_datetime()
.unwrap();
target_datetime.weekday().num_days_from_monday() as usize
}
fn check_review_distribution(actual_reviews: &[usize], percentages: &[f32]) -> Vec<f32> {
if percentages.iter().sum::<f32>() == 0.0 {
return vec![1.0; actual_reviews.len()];
}
let total_actual = actual_reviews.iter().sum::<usize>() as f32;
let expected_distribution: Vec<f32> = percentages
.iter()
.map(|&p| p * (total_actual / percentages.iter().sum::<f32>()))
.collect();
expected_distribution
.iter()
.zip(actual_reviews.iter())
.map(|(&e, &a)| (e - a as f32).max(0.0))
.collect()
}

View file

@ -25,6 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { DeckOptionsState } from "./lib";
import NewOptions from "./NewOptions.svelte";
import TimerOptions from "./TimerOptions.svelte";
import EasyDays from "./EasyDays.svelte";
export let state: DeckOptionsState;
const addons = state.addonComponents;
@ -57,6 +58,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const timerOptions = {};
export const audioOptions = {};
export const advancedOptions = {};
export const easyDays = {};
let onPresetChange: () => void;
</script>
@ -88,11 +90,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Row>
<Row class="row-columns">
<BuryOptions {state} api={buryOptions} />
<FsrsOptionsOuter {state} api={{}} />
</Row>
</div>
<div>
<Row class="row-columns">
<BuryOptions {state} api={buryOptions} />
</Row>
<Row class="row-columns">
<AudioOptions {state} api={audioOptions} />
</Row>
@ -112,7 +118,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if}
<Row class="row-columns">
<FsrsOptionsOuter {state} api={{}} />
<EasyDays {state} api={easyDays} />
</Row>
<Row class="row-columns">

View file

@ -0,0 +1,97 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte";
import Item from "$lib/components/Item.svelte";
import TitledContainer from "$lib/components/TitledContainer.svelte";
import type { DeckOptionsState } from "./lib";
export let state: DeckOptionsState;
export let api: Record<string, never>;
const config = state.currentConfig;
const defaults = state.defaults;
if ($config.easyDaysPercentages.length !== 7) {
$config.easyDaysPercentages = defaults.easyDaysPercentages;
}
const easyDays = [
tr.deckConfigEasyDaysMonday(),
tr.deckConfigEasyDaysTuesday(),
tr.deckConfigEasyDaysWednesday(),
tr.deckConfigEasyDaysThursday(),
tr.deckConfigEasyDaysFriday(),
tr.deckConfigEasyDaysSaturday(),
tr.deckConfigEasyDaysSunday(),
];
</script>
<TitledContainer title={tr.deckConfigEasyDaysTitle()}>
<DynamicallySlottable slotHost={Item} {api}>
<Item>
<div class="easy-days-settings">
<table>
<thead>
<tr>
<th></th>
<th>{tr.deckConfigEasyDaysNormal()}</th>
<th>{tr.deckConfigEasyDaysReduced()}</th>
<th>{tr.deckConfigEasyDaysMinimum()}</th>
</tr>
</thead>
<tbody>
{#each easyDays as day, index}
<tr>
<td>{day}</td>
<td>
<input
type="radio"
bind:group={$config.easyDaysPercentages[index]}
value={1.0}
checked={$config.easyDaysPercentages[index] ===
1.0}
/>
</td>
<td>
<input
type="radio"
bind:group={$config.easyDaysPercentages[index]}
value={0.5}
checked={$config.easyDaysPercentages[index] ===
0.5}
/>
</td>
<td>
<input
type="radio"
bind:group={$config.easyDaysPercentages[index]}
value={0.0}
checked={$config.easyDaysPercentages[index] ===
0.0}
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Item>
</DynamicallySlottable>
</TitledContainer>
<style>
.easy-days-settings table {
width: 100%;
border-collapse: collapse;
}
.easy-days-settings th,
.easy-days-settings td {
padding: 8px;
text-align: center;
border: 1px solid #ddd;
}
</style>

View file

@ -111,7 +111,7 @@ export function prepareData(
},
{
label: tr.statisticsEstimatedTotalKnowledge(),
value: tr.statisticsCards({ cards: data.sum }),
value: tr.statisticsCards({ cards: +data.sum.toFixed(0) }),
},
];

View file

@ -81,6 +81,7 @@ export function renderSimulationChart(
)
.attr("direction", "ltr");
svg.select(".y-ticks .y-axis-title").remove();
svg.select(".y-ticks")
.append("text")
.attr("class", "y-axis-title")