This commit is contained in:
Luc Mcgrady 2025-12-21 23:56:32 +01:00 committed by GitHub
commit 715fbb92c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 13 deletions

View file

@ -513,9 +513,8 @@ deck-config-save-options-to-preset-confirm = Overwrite the options in your curre
# to show the total number of cards that can be recalled or retrieved on a
# specific date.
deck-config-fsrs-simulator-radio-memorized = Memorized
deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio
# $time here is pre-formatted e.g. "10 Seconds"
deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card
deck-config-fsrs-simulator-radio-ratio2 = Memorized / Time Ratio
deck-config-fsrs-simulator-ratio-tooltip2 = { $time } memorized cards per hour
## Messages related to the FSRS schedulers health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function.
@ -536,6 +535,9 @@ deck-config-fsrs-good-fit = Health Check:
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio
# $time here is pre-formatted e.g. "10 Seconds"
deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card
deck-config-unable-to-determine-desired-retention =
Unable to determine a minimum recommended retention.
deck-config-predicted-minimum-recommended-retention = Minimum recommended retention: { $num }

View file

@ -420,8 +420,9 @@ message SimulateFsrsReviewResponse {
message SimulateFsrsWorkloadResponse {
map<uint32, float> cost = 1;
map<uint32, float> memorized = 2;
map<uint32, uint32> review_count = 3;
float reviewless_end_memorized = 2;
map<uint32, float> memorized = 3;
map<uint32, uint32> review_count = 4;
}
message ComputeOptimalRetentionResponse {

View file

@ -300,7 +300,11 @@ impl Collection {
))
})
.collect::<Result<HashMap<_, _>>>()?;
let reviewless_end_memorized = cards.iter().fold(0., |p, c| {
p + c.retention_on(&req.params, req.days_to_simulate as f32)
});
Ok(SimulateFsrsWorkloadResponse {
reviewless_end_memorized,
memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(),
cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(),
review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(),

View file

@ -212,6 +212,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
x: parseInt(dr),
timeCost: resp!.cost[dr],
memorized: v,
reviewless_end_memorized: resp!.reviewlessEndMemorized,
count: resp!.reviewCount[dr],
label: simulationNumber,
learnSpan: simulateFsrsRequest.daysToSimulate,
@ -570,7 +571,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={SimulateWorkloadSubgraph.ratio}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioRatio()}
{tr.deckConfigFsrsSimulatorRadioRatio2()}
</label>
<label>
<input

View file

@ -8,12 +8,16 @@ import {
bisector,
line,
max,
min,
pointer,
rollup,
type ScaleLinear,
scaleLinear,
type ScaleTime,
scaleTime,
schemeCategory10,
select,
type Selection,
} from "d3";
import * as tr from "@generated/ftl";
@ -33,6 +37,7 @@ export interface Point {
export type WorkloadPoint = Point & {
learnSpan: number;
reviewless_end_memorized: number;
};
export enum SimulateSubgraph {
@ -62,14 +67,17 @@ export function renderWorkloadChart(
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const subgraph_data = ({
[SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })),
[SimulateWorkloadSubgraph.ratio]: data.map(d => ({
...d,
y: (60 * 60 * (d.memorized - d.reviewless_end_memorized)) / d.timeCost,
})),
[SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })),
[SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })),
[SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })),
})[subgraph];
const yTickFormat = (n: number): string => {
return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio
return subgraph == SimulateWorkloadSubgraph.time
? timeSpan(n, true)
: n.toString();
};
@ -83,7 +91,7 @@ export function renderWorkloadChart(
const formatY: (value: number) => string = ({
[SimulateWorkloadSubgraph.ratio]: (value: number) =>
tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }),
tr.deckConfigFsrsSimulatorRatioTooltip2({ time: value.toFixed(2) }),
[SimulateWorkloadSubgraph.time]: (value: number) =>
tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }),
[SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }),
@ -95,6 +103,19 @@ export function renderWorkloadChart(
return `${tr.deckConfigDesiredRetention()}: ${xTickFormat(dr)}<br>`;
}
select(svgElem)
.enter()
.datum(subgraph_data[subgraph_data.length - 1])
.append("line")
.attr("x1", bounds.marginLeft)
.attr("x2", bounds.width - bounds.marginRight)
.attr("y1", bounds.marginTop)
.attr("y2", bounds.marginTop)
.attr("stroke", "black")
.attr("stroke-width", 1);
const startMemorized = subgraph_data[0].reviewless_end_memorized;
return _renderSimulationChart(
svgElem,
bounds,
@ -105,6 +126,20 @@ export function renderWorkloadChart(
(_e: MouseEvent, _d: number) => undefined,
yTickFormat,
xTickFormat,
(svg, x, y) => {
svg
.selectAll("line")
.data(subgraph == SimulateWorkloadSubgraph.memorized ? [startMemorized] : [])
.enter()
.attr("x1", x(xMin))
.attr("x2", x(xMax))
.attr("y1", d => y(d))
.attr("y2", d => y(d))
.attr("stroke", "black")
.attr("stroke-dasharray", "5,5")
.attr("stroke-width", 1);
},
subgraph == SimulateWorkloadSubgraph.memorized ? startMemorized : 0,
);
}
@ -185,16 +220,25 @@ export function renderSimulationChart(
);
}
function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
function _renderSimulationChart<
X extends ScaleLinear<number, number> | ScaleTime<number, number>,
T extends { x: any; y: any; label: number },
>(
svgElem: SVGElement,
bounds: GraphBounds,
subgraph_data: T[],
x: any,
x: X,
formatY: (n: T["y"]) => string,
formatX: (n: T["x"]) => string,
legendMouseMove: (e: MouseEvent, d: number) => void,
yTickFormat?: (n: number) => string,
xTickFormat?: (n: number) => string,
renderExtra?: (
svg: Selection<SVGElement, unknown, null, undefined>,
x: X,
y: ScaleLinear<number, number, never>,
) => void,
y_min = Infinity,
): TableDatum[] {
const svg = select(svgElem);
svg.selectAll(".lines").remove();
@ -215,9 +259,11 @@ function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
// y scale
const yMax = max(subgraph_data, d => d.y)!;
let yMin = min(subgraph_data, d => d.y)!;
yMin = min([yMin, y_min])!;
const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
.domain([yMin, yMax])
.nice();
svg.select<SVGGElement>(".y-ticks")
.call((selection) =>
@ -242,7 +288,7 @@ function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
.attr("fill", "currentColor");
// x lines
const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]);
const points = subgraph_data.map((d) => [x(d.x)!, y(d.y)!, d.label]);
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
const color = schemeCategory10;
@ -364,6 +410,8 @@ function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
setDataAvailable(svg, true);
renderExtra?.(svg, x, y);
const tableData: TableDatum[] = [];
return tableData;