Repository navigation
Expand file tree
/
Copy pathstudyDigest.ts
More file actions
356 lines (334 loc) · 13.5 KB
/
Copy pathstudyDigest.ts
File metadata and controls
356 lines (334 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
import type { Answer } from "./answer";
import { chainIndexOf } from "./answerChain";
import { activeCards, promptsOf, type Card, type DeckDirection } from "./deck";
import type { Scheduler } from "./fsrs";
import type { StudyPreferences } from "./preferences";
import { reviewKeyOf, type DueState } from "./review";
import { isStudyDay } from "./reviewStateEdits";
import { studyDayOf } from "./scheduling";
import {
dailyActivity,
retentionOf,
streaksOf,
type DayCounts,
type Recall,
type StudyDayFigures,
} from "./statistics";
import { calendarStartOf, periodToDateOf } from "./studyPatterns";
import { ANSWER_CAP_MS, recordedTimeOf } from "./studyTime";
/**
* What the deck list needs to know of a deck, computed once from its
* cards and review states and kept (in the instance's digest) for the
* next visit: how many prompts fall due on which study day, how many are
* still new, and how many were reviewed and introduced on the day it was
* computed. With today's preferences that gives the same counts as
* buildStudyQueue, without the cards or the reviews.
*
* It holds while neither document changes (the digest stamps their
* versions), the deck's direction is the same, the day boundary too, and
* the scheduler whose due days it counts is still the one in use.
*/
export interface DeckSchedule {
direction: DeckDirection;
dayBoundaryHour: number;
/** The scheduler whose due days it counts: switching sets other days without a write to the reviews. */
scheduler: Scheduler;
/** Prompts with review state, by the study day they are due: a state whose due day is no real day (isStudyDay) is left out. */
dueByDay: Record<string, number>;
/** Prompts never reviewed. */
unreviewed: number;
/** The study day it was computed on. */
studyDay: string;
/** Reviews made on that day, of any card (what buildStudyQueue counts). */
reviewedOnDay: number;
/** Prompts first reviewed on that day. */
introducedOnDay: number;
}
/** The deck's schedule from its prompts' due days, those of `scheduler` (effectiveStates). */
export function scheduleOf(input: {
cards: Card[];
direction: DeckDirection;
reviews: readonly DueState[];
scheduler: Scheduler;
dayBoundaryHour: number;
now: Date;
}): DeckSchedule {
const { cards, direction, reviews, scheduler, dayBoundaryHour, now } = input;
const studyDay = studyDayOf(now, dayBoundaryHour);
const reviewOf = new Map(reviews.map((r) => [reviewKeyOf(r), r]));
const dueByDay: Record<string, number> = {};
let unreviewed = 0;
for (const prompt of promptsOf(activeCards(cards), direction)) {
const review = reviewOf.get(
reviewKeyOf({ cardId: prompt.card.id, direction: prompt.direction }),
);
if (review === undefined) unreviewed++;
// Read leniently, a due day may be anything; the digest's shape holds only real ones, and one it refuses would take the changes written with it along.
else if (isStudyDay(review.due)) dueByDay[review.due] = (dueByDay[review.due] ?? 0) + 1;
}
const onDay = (instant: string) => studyDayOf(new Date(instant), dayBoundaryHour) === studyDay;
return {
direction,
dayBoundaryHour,
scheduler,
dueByDay,
unreviewed,
studyDay,
reviewedOnDay: reviews.filter((r) => onDay(r.lastReviewedAt)).length,
introducedOnDay: reviews.filter((r) => onDay(r.firstReviewedAt)).length,
};
}
/**
* Today's counts from a schedule, as buildStudyQueue would give them;
* null when the schedule cannot say (another direction, day boundary or
* scheduler, or computed on a later day than today: the clock went back).
*/
export function studyCountsOf(
schedule: DeckSchedule,
input: { direction: DeckDirection; prefs: StudyPreferences; now: Date },
): { dueCount: number; newCount: number } | null {
const { direction, prefs, now } = input;
if (
schedule.direction !== direction ||
schedule.dayBoundaryHour !== prefs.dayBoundaryHour ||
schedule.scheduler !== prefs.scheduler
)
return null;
const today = studyDayOf(now, prefs.dayBoundaryHour);
if (schedule.studyDay > today) return null;
// Any review since the schedule was computed changed the reviews document, so on a later day none was made yet.
const sameDay = schedule.studyDay === today;
const reviewedToday = sameDay ? schedule.reviewedOnDay : 0;
const introducedToday = sameDay ? schedule.introducedOnDay : 0;
const due = Object.entries(schedule.dueByDay)
.filter(([day]) => day <= today)
.reduce((sum, [, count]) => sum + count, 0);
return {
dueCount: Math.min(due, Math.max(0, prefs.maxReviewsPerDay - reviewedToday)),
newCount: Math.min(schedule.unreviewed, Math.max(0, prefs.newCardsPerDay - introducedToday)),
};
}
/**
* What the today strip needs of one study month of the answer log,
* summarized once from its answers and kept (in the instance's digest)
* for the next visit: each study day's answers, introductions, scored
* reviews and lapses, the month's recall of young prompts, and how long
* its recalls took. Whether an answer repeats another depends on the
* answer before it to its prompt (answerChain.ts isRepeat), of the same
* month while answer times and study days run in the same order, so the
* counts are those statisticsOf makes of every month at once; they can
* differ only after the day boundary or the time zone changed in the
* night of a month's end. The times are counted by the whole
* second, since a median cannot be put together from medians.
*
* It holds while the month's document does not change (the digest
* stamps its version). Each answer keeps the study day it was given on,
* so a change of the day boundary changes none of it.
*/
export interface MonthSummary {
/** Every study day of the month with answers, oldest first. */
days: DayCounts[];
/** Its scored reviews of young prompts (Retention.young). */
young: Recall;
/**
* Its recalled answers with a recorded time (recordedTimeOf), by the
* whole seconds they took, a time of ANSWER_CAP_MS or more counted at
* the cap: how many took each.
*/
recallSeconds: Record<number, number>;
}
const STUDY_DAY = /^\d{4}-\d{2}-\d{2}$/;
/** The most seconds a recall time counts for in a month summary: ANSWER_CAP_MS's. */
export const RECALL_SECONDS_CAP = ANSWER_CAP_MS / 1000;
/** The summary of the answers of one study month (MonthSummary). */
export function monthSummaryOf(answers: readonly Answer[]): MonthSummary {
const index = chainIndexOf(answers);
const recallSeconds: Record<number, number> = {};
for (const answer of answers) {
const ms = answer.mode === "multiple-choice" ? undefined : recordedTimeOf(answer);
if (ms === undefined) continue;
// The log reads its times bounded; a time below zero given otherwise counts as none, the least its shape has.
const seconds = Math.min(Math.max(0, Math.floor(ms / 1000)), RECALL_SECONDS_CAP);
recallSeconds[seconds] = (recallSeconds[seconds] ?? 0) + 1;
}
return {
// A study day not written "YYYY-MM-DD", as an answer read leniently may have it, is left out.
days: dailyActivity(answers, index)
.filter((day) => STUDY_DAY.test(day.studyDay))
.map(({ studyDay, answers, introduced, reviews, lapses }) => ({
studyDay,
answers,
introduced,
reviews,
lapses,
})),
young: retentionOf(answers, index).young,
recallSeconds,
};
}
/**
* What the today strip shows (todayOf, welcomeBackOf) and estimates the
* time left today with (expectedTimeToday).
*/
export interface TodayFigures extends StudyDayFigures {
/** How well young prompts were remembered. */
young: Recall;
/** The median time a recalled answer took, by its recorded times; null while none is known. */
medianAnswerMs: number | null;
}
/**
* The today strip's figures of the summaries of the months it looks
* back over, as statisticsOf makes them of those months' answers with
* the range's first study day `since` (the first of a month): today's
* study, the streaks and this week beside the last of the days from
* `since` or from the activity calendar's first (calendarStartOf), the
* earlier, and the recall of young prompts and the median answer time of
* the months from `since` on, a summary being of the month of its days.
* Only the median answer time can differ: each time counts as the
* middle of its second (the cap as the cap), so it is off by half a
* second at most below the cap. While no time was recorded it is null,
* where statisticsOf estimates one from the pauses between answers.
*/
export function todayFiguresOf(
summaries: readonly MonthSummary[],
today: string,
since: string,
): TodayFigures {
const calendarStart = calendarStartOf(today);
const from = since < calendarStart ? since : calendarStart;
const days = summaries
.flatMap((summary) => summary.days)
.filter((day) => day.studyDay >= from)
.sort((a, b) => a.studyDay.localeCompare(b.studyDay));
const young: Recall = { reviews: 0, recalled: 0 };
const counts = Array.from({ length: RECALL_SECONDS_CAP + 1 }, () => 0);
for (const summary of summaries) {
if ((summary.days[0]?.studyDay ?? "") < since) continue;
young.reviews += summary.young.reviews;
young.recalled += summary.young.recalled;
for (const [seconds, count] of Object.entries(summary.recallSeconds))
counts[Number(seconds)]! += count;
}
return {
today,
days,
streaks: streaksOf(
days.map((day) => day.studyDay),
today,
),
week: periodToDateOf(days, today, "week"),
young,
medianAnswerMs: medianOfSeconds(counts),
};
}
/** The median of the times counted by second (`counts[s]` took s seconds), in milliseconds; null of none. */
function medianOfSeconds(counts: readonly number[]): number | null {
const total = counts.reduce((sum, count) => sum + count, 0);
if (total === 0) return null;
const at = (rank: number) => {
let seconds = 0;
for (let below = counts[0]!; below <= rank; below += counts[seconds]!) seconds += 1;
return seconds === RECALL_SECONDS_CAP ? ANSWER_CAP_MS : seconds * 1000 + 500;
};
const middle = Math.floor(total / 2);
return total % 2 === 1 ? at(middle) : (at(middle - 1) + at(middle)) / 2;
}
/**
* An instance's digest (its digest.ttl): what was learned of its
* documents, each at the version (ETag) it was learned at, so a visit
* need not download a document that has not changed since. Derived
* data: whatever does not hold is learned again.
*/
export interface InstanceDigest {
/** By document URL. */
receipts: Record<string, DocumentReceipt>;
/** By deck URL. */
schedules: Record<string, StoredSchedule>;
/** By the URL of the answer log's month document. */
months: Record<string, StoredMonthSummary>;
}
/** What a document was found to be at one version. */
export interface DocumentReceipt {
document: string;
version: string;
/** It conformed to the shapes checked by these rules (a hash of them). */
conformedTo?: string;
/** Nothing in it was in an older format. */
latestFormat?: true;
}
/** A deck's schedule with the versions of its documents it was computed from. */
export interface StoredSchedule {
deck: string;
cardsVersion: string;
reviewsVersion: string;
schedule: DeckSchedule;
}
/** A month's summary with the version of its document of the answer log it was made from. */
export interface StoredMonthSummary {
/** The month's document (instanceLayout.ts historyUrlOf). */
document: string;
version: string;
summary: MonthSummary;
}
/** The version of a document there is none of: its absence too can be known and checked again. */
export const ABSENT_VERSION = "absent";
export function emptyDigest(): InstanceDigest {
return { receipts: {}, schedules: {}, months: {} };
}
/**
* A change to a digest, made in place to a working copy of it
* (changedDigest), so that many changes at once take time that grows
* with the digest and the changes, not their product: each would copy
* the digest's entries if it made a new one.
*/
export type DigestChange = (draft: InstanceDigest) => void;
/**
* The digest (an empty one for none) with the changes made to it in
* order: to one copy of it, `digest` left as it was. Its entries are
* replaced, never changed, so the copy shares those it keeps.
*/
export function changedDigest(
digest: InstanceDigest | null,
changes: readonly DigestChange[],
): InstanceDigest {
const draft =
digest === null
? emptyDigest()
: {
receipts: { ...digest.receipts },
schedules: { ...digest.schedules },
months: { ...digest.months },
};
for (const change of changes) change(draft);
return draft;
}
/**
* Note what is known of a document at a version: added to what was
* known of that version, replacing what was known of another.
*/
export function addReceipt(
draft: InstanceDigest,
document: string,
version: string,
facts: Pick<DocumentReceipt, "conformedTo" | "latestFormat">,
): void {
const before = draft.receipts[document];
const kept = before?.version === version ? before : { document, version };
draft.receipts[document] = { ...kept, ...facts };
}
export function setSchedule(draft: InstanceDigest, stored: StoredSchedule): void {
draft.schedules[stored.deck] = stored;
}
/**
* Put the month summaries given, by document, in place of those the
* digest had: a month no longer looked back over is dropped.
*/
export function setMonthSummaries(
draft: InstanceDigest,
months: Record<string, StoredMonthSummary>,
): void {
draft.months = { ...months };
}