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

Fix/fallback to non-manual entry when first_of_last_learn_entries non found #3639

Conversation

L-M-Sherlock
Copy link
Contributor

fix #3634

@user1823
Copy link
Contributor

user1823 commented Dec 15, 2024

Just to be sure, non_manual_entries.is_some() is true only if there are non-manual entries AFTER the Reset entry, right?

Did you try this on a card that has no entries after a reset entry?

@L-M-Sherlock
Copy link
Contributor Author

L-M-Sherlock commented Dec 15, 2024

Sure:

image

@L-M-Sherlock
Copy link
Contributor Author

Wait a minute... The single_card_revlog_to_items is too complex than my thoughts... The current implementation uses the first non-manual entry instead of the first non-manual entry after the Reset entry. I will fix it.

@L-M-Sherlock
Copy link
Contributor Author

@dae, sorry for bothering you, but I don't understand this snippet of code:

if !revlogs_complete {
revlogs_complete = matches!(
entries.first(),
Some(RevlogEntry {
review_kind: RevlogReviewKind::Manual,
..
}) | Some(RevlogEntry {
review_kind: RevlogReviewKind::Rescheduled,
..
})
);
}

What's the aim of it?

@dae
Copy link
Member

dae commented Dec 15, 2024

Introduced in db93939.

I believe the goal was to treat them as complete even if no learning entries have been found, if they started with a reschedule?

@user1823
Copy link
Contributor

user1823 commented Dec 15, 2024

Dae added that code to implement my suggestion in open-spaced-repetition/fsrs4anki#540 (comment)

The issue: open-spaced-repetition/fsrs4anki#557

If the user uses Set Due Date to introduce a card rather than by normal way, the cards were getting very high difficulty.

Basically, it is the same issue that has been discussed many times in open-spaced-repetition/fsrs4anki#675.

@L-M-Sherlock
Copy link
Contributor Author

My PR will infer the memory state from the first non-manual entry aftet Reset and set revlogs_complete to false. But this snippet of code will reverse my operation in some cases.

@user1823
Copy link
Contributor

In open-spaced-repetition/fsrs4anki#675, I suggested removing this snippet. So, I think that you can remove it.

@L-M-Sherlock
Copy link
Contributor Author

L-M-Sherlock commented Dec 15, 2024

OK, here is the next problem:

let first_review = entries
.iter()
// ignore manual and rescheduled revlogs and revlogs before the cutoff
.find(|e| e.button_chosen > 0 && e.id.0 >= ignore_revlogs_before.0)
.map(|e| FirstReview {
interval: e.interval.max(1) as f32,
ease_factor: if e.ease_factor == 0 {
2500
} else {
e.ease_factor
} as f32
/ 1000.0,
});

I think first_review should be extracted from the items returned by single_card_revlog_to_items instead of the original review entries.

if let Some((mut items, revlogs_complete, _)) =
single_card_revlog_to_items(entries, next_day_at, false, ignore_revlogs_before)
{
let mut item = items.pop().unwrap();
if revlogs_complete {
Ok(Some(FsrsItemWithStartingState {
item,
starting_state: None,
}))
} else if let Some(first_review) = first_review {
// the revlog has been truncated, but not fully
let mut starting_state = fsrs.memory_state_from_sm2(
first_review.ease_factor,
first_review.interval,
historical_retention,
)?;

The current code infers the memory state from the first_review which doesn't consider Reset entries.

@user1823
Copy link
Contributor

I think first_review should be extracted from the items returned by single_card_revlog_to_items instead of the original review entries.

Your suggestion seems reasonable to me.

@L-M-Sherlock
Copy link
Contributor Author

So this unit test should be removed.

// but if there's only a single revlog entry, we'll fall back on current card
// state
let item = single_card_revlog_to_item(
&fsrs,
vec![RevlogEntry {
ease_factor: 2500,
interval: 100,
..revlog(RevlogReviewKind::Review, 100)
}],
TimestampSecs::now(),
0.9,
0.into(),
)?;
assert!(item.is_none());
card.interval = 123;
card.ease_factor = 2000;
card.ctype = CardType::Review;
card.set_memory_state(&fsrs, item, 0.9)?;
assert_int_eq(
card.memory_state,
Some(FsrsMemoryState {
stability: 122.99994,
difficulty: 7.334526,
}),
);

@user1823
Copy link
Contributor

If we find a relearning entry, should we set the revlogs_complete to true?

What did the previous code do? I will have to search through previous discussions for reasoning for/against this. Unfortunately, I won't be able to devote much time today and tomorrow.

@user1823
Copy link
Contributor

On a gross overview, the changes made till now look good to me.

@L-M-Sherlock
Copy link
Contributor Author

Here is the previous code:

let first_relearn = entries
.iter()
.enumerate()
.find(|(_idx, e)| {
e.id.0 > ignore_revlogs_before.0 && e.review_kind == RevlogReviewKind::Relearning
})
.map(|(idx, _)| idx);
if let Some(idx) = first_of_last_learn_entries.or(first_relearn) {
// start from the (re)learning step
if idx > 0 {
entries.drain(..idx);
}

It was introduced in 90b0c6d

@user1823
Copy link
Contributor

If the previous code set it to true, then let's keep it that way. There must be some reason why it was done like that.

@L-M-Sherlock
Copy link
Contributor Author

If the previous code set it to true, then let's keep it that way. There must be some reason why it was done like that.

In previous code, revlogs_complete is true only when Anki finds the first_of_last_learn_entries or the first entry is Manual or Rescheduled.

@L-M-Sherlock
Copy link
Contributor Author

If revlogs_complete is false, Anki will infer the memory state from the relearn entry. If it's true, the rating of the relearn entry will be treated as the first rating.

@user1823
Copy link
Contributor

If we find a relearning entry, should we set the revlogs_complete to true?

My initial answer would be NO.

But, what does 90b0c6d do?

@user1823
Copy link
Contributor

Does this PR also fix #2921?

@user1823

This comment was marked as resolved.

@L-M-Sherlock
Copy link
Contributor Author

Does this PR also fix #2921?

Yes. The starting_state is converted from the first review of that affected card.

@L-M-Sherlock
Copy link
Contributor Author

But, what does 90b0c6d do?

If the first_of_last_learn_entries is non-found, Anki will try to find the first_relearn_entry. If it's found, Anki will remove all entries before this entry. In the commit version, the first_relearn_entry was treated as the first learning entry. Its rating was used to generate the initial stability and difficulty.

@user1823
Copy link
Contributor

user1823 commented Dec 16, 2024

Its rating was used to generate the initial stability and difficulty.

Using normal formula or using memory_state_from_sm2? Let's keep doing whatever Anki was doing before this PR. It would have better if we had a link to discussion in the commit message of 90b0c6d.

@L-M-Sherlock
Copy link
Contributor Author

Using normal formula or using memory_state_from_sm2?

Using normal formula. But it was changed in db93939. This commit introduced revlogs_complete. And revlogs_complete is true only when last_learn_entry is found. If revlogs_complete is false, Anki will use memory_state_from_sm2.

@user1823
Copy link
Contributor

user1823 commented Dec 16, 2024

But it was changed in db93939.

Well, if this is what has happened, using memory_state_from_sm2 was a bug. But, this change was made 1 year ago and no one has complained. So, let's keep revlogs_complete as False in that case for now.

@user1823
Copy link
Contributor

Next issue/suggestion:

We should also exclude cram revlogs (entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0) here.

        if matches!(entry.button_chosen, 1..=4) {
            non_manual_entries = Some(index);
        }

This is the last suggestion from me for today. I will take another look tomorrow to ensure that no issue is remaining and then we can request dae for a final review.

By the way, thanks for this PR. You have squashed tons of bugs with this.

@L-M-Sherlock
Copy link
Contributor Author

L-M-Sherlock commented Dec 16, 2024

I refactored a lot of codes.

Cram revlogs are skipped

    for (index, entry) in entries.iter().enumerate().rev() {
        if entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0 {
            continue;
        }

Only search the first non-manual revlog entry after ignore date

        if matches!(entry.button_chosen, 1..=4) && entry.id.0 > ignore_revlogs_before.0 {
            non_manual_entries = Some(index);
        }

Fallback to non-manual revlog entry if the first of last learn entires is before ignore date

I referred to your idea: #2922 (comment)

        // While reviewing if the first learning step is before the ignore date,
        // fallback to non_manual_entries
        if let Some(idx) = first_of_last_learn_entries {
            if entries[idx].id.0 < ignore_revlogs_before.0 && idx < entries.len() - 1 {
                revlogs_complete = false;
                first_of_last_learn_entries = None;
            }
        }

- fsrs_items_for_memory_state - fsrs_items_for_memory_states
- single_card_revlog_to_item -> fsrs_item_for_memory_state
(to match fsrs_items_for_training)
- single_card_revlog_to_items -> reviews_for_fsrs
- Use struct instead of tuple for reviews_for_fsrs output
- Don't return count, since we're already returning the filtered list
@dae
Copy link
Member

dae commented Dec 17, 2024

Sorry, I forgot we iterate on the entries in reverse, which I think has made my renaming incorrect. Reviewing...

- non_manual_entries -> first_user_grade_idx
- change comments to reflect the fact that we're working backwards
- Use "user-graded" rather than "non-manual"
@dae dae force-pushed the Fix/fallback-to-non-manual-entry-when-first_of_last_learn_entries-non-found branch from c056189 to 7fd9a77 Compare December 17, 2024 09:44
@dae
Copy link
Member

dae commented Dec 17, 2024

Ok, sorry about the delay. Tests past after the changes in the last commit, but I'm not 100% confident the behaviour is identical - can you spot any problems?

@L-M-Sherlock
Copy link
Contributor Author

For this case, the revlog_complete should be false.

L | L R

@user1823
Copy link
Contributor

user1823 commented Dec 17, 2024

The last commit (7fd9a77) has introduced a functional change (I think) and it isn't desirable.

After the above commit, if a card has revlog entries like L L | L R R, this card

  • is used for training
  • gets it memory states using normal FSRS formulas (instead of memory_states_from_sm2) at the L entry just after the cutoff.

where | = cutoff date

Maybe, this type of revlog entry requires a unit test because it is too easy to break.

@user1823
Copy link
Contributor

Apart from the above mentioned functional issue, the code looks good to me.

However, I have some suggestions regarding wording:

memory_state.rs

  • rename first_non_manual_entry to first_user_graded_entry (or something like that)
  • change this comment
        // but if there's only a single revlog entry, we'll fall back on the first
        // non-manual entry

to

        // cards with a single review-type entry also get memory states from revlog rather than card states

params.rs

  • change this comment
        // While reviewing, if the first learning step is before the ignore date,
        // we won't start from the learning step.

to

        // While reviewing, if the first learning step is before the ignore date,
        // infer memory states from sm2 at the revlog entry just after.
  • change this comment
        // if there are no learning entries, but the user has reviewed the card,
        // we ignore all entries before the first grade

to

        // ignore all entries before the first user-graded rating matched by the loop

@dae dae force-pushed the Fix/fallback-to-non-manual-entry-when-first_of_last_learn_entries-non-found branch from 7fd9a77 to 51d2d61 Compare December 17, 2024 11:14
@dae
Copy link
Member

dae commented Dec 17, 2024

Thank you both, I've reverted my changes and applied some of the other suggestions.

@dae dae merged commit 474dbc2 into ankitects:main Dec 17, 2024
1 check passed
@L-M-Sherlock L-M-Sherlock deleted the Fix/fallback-to-non-manual-entry-when-first_of_last_learn_entries-non-found branch December 17, 2024 13:03
user1823 added a commit to user1823/anki that referenced this pull request Dec 28, 2024
Covers most of the cases encountered in ankitects#3639
@user1823 user1823 mentioned this pull request Dec 28, 2024
abdnh pushed a commit that referenced this pull request Jan 4, 2025
* Add some unit tests

Covers most of the cases encountered in #3639

* Format

* Update params.rs

Makes the test more robust.

* Update params.rs

When training, the first FSRS item is removed. That's why none of the other tests includes it.

Co-authored-by: Jarrett Ye <[email protected]>

* Improve naming

* Fix typo

---------

Co-authored-by: Jarrett Ye <[email protected]>
user1823 added a commit to user1823/anki that referenced this pull request Jan 15, 2025
Complements the change in ankitects#3639, ensuring that scheduler and rescheduling produce the same results.
dae pushed a commit that referenced this pull request Jan 17, 2025
Complements the change in #3639, ensuring that scheduler and rescheduling produce the same results.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Set due date can confuse FSRS's calculations
3 participants