FSRS Spaced Repetition for Chess
The mathematical formulation of FSRS-4.5/5 DSR power-law forgetting curves adapted for chess opening recall.
ChessSRS Scheduling Architecture: A Graph-Aware DSR Model
by claude-sonnet-5-high
Executive Summary
Recommendation: Adopt the FSRS-4.5/5 (Difficulty-Stability-Retrievability) power-law forgetting model as the per-node memory kernel, wrapped in a chess-specific graph propagation layer (GraphAwareReviewCoordinator) that is not part of generic Anki/FSRS and cannot be bolted on later without a redesign. The kernel handles "how does one memory item decay and strengthen." The wrapper handles "how do memory items influence each other because they live on a tree of forced move-order dependencies, share transposed positions, and compete for the same pattern-recognition slot."
This separation is deliberate: the DSR core is empirically the strongest general memory model available (outperforms SM-2/HLR in every published Anki-scale benchmark), so we should not re-derive it from scratch with no chess review data to fit it on. The genuinely novel, chess-specific engineering is entirely in the graph layer — that's where this report concentrates its original contribution.
A. Algorithmic Candidates Comparison
| Model | Mechanism | Fit for chess trees | Verdict |
|---|---|---|---|
| SM-2 / Leitner (Chessable-style) | Discrete ease-factor ladder, deterministic multiplier on correct, hard reset on lapse | Zero graph awareness, ease factor is a crude scalar that conflates "how hard is this move" with "how forgettable is this move," reset-on-lapse is catastrophic for an 8-ply variation (see §B.1) | Reject as primary model. Useful only as the SimpleScheduler fallback for cold-start/offline-degraded mode. |
| EaseScalingScheduler (current) | I = \text{ease}\times\text{scaling}^{\text{reps}}, geometric, no decay-based retrievability target |
No retention target, no difficulty separate from stability, cannot answer "when will R drop below 90%" — it's a schedule, not a memory model |
Deprecate. Keep only as legacy import path for existing users migrating data. |
| FSRS v4.5 / v5 (DSR) | Continuous power-law retrievability R(t,S), difficulty D\in[1,10] decoupled from stability S, explicit R_{\text{target}}-driven interval solve |
Excellent: separates "intrinsically hard move" (D) from "currently well/poorly memorized" (S); directly answers the tournament "R≥95% by Saturday" question by solving for t at fixed S; validated on >700M real reviews |
Adopt as kernel. |
| Half-Life Regression (HLR/Duolingo) | p = 2^{-\Delta/h}, h = 2^{\theta\cdot x}, features regressed via logistic/linear regression over large corpora |
Needs a trained feature vector (lexeme features in Duolingo); we have no chess-specific corpus yet, and it doesn't model difficulty separately from stability — architecturally it is FSRS's ancestor with a weaker (exponential, not power-law) decay | Reject as core model, but its methodology (log-loss regression to fit decay-rate weights against real review outcomes) is exactly how we should later refit FSRS's w_i chess-specific weights once ChessSRS accumulates review logs. |
| Hierarchical / Graph-aware (tree credit assignment, Bayesian Knowledge Tracing) | BKT: latent binary "known" state per skill, HMM transition (learn/forget/guess/slip probabilities), naturally supports prerequisite graphs | Correctly models "prerequisite" structure but BKT is trial-indexed, not time-indexed — it has no native concept of "14 real days elapsed," which is the entire point of SRS. Also binary latent state loses the difficulty/stability separation. | Do not replace FSRS with BKT. Instead, borrow BKT's prerequisite-propagation idea (§B.1, §B.4) purely for cold-start priors (new/child-node difficulty seeded from ancestor mastery) — a genuine hybrid, not a swap. |
On binary ratings vs. FSRS's 4-button model (Decision D015: Pure Binary FSRS)
FSRS's canonical equations are parameterized on g \in {1{=}\text{Again},2{=}\text{Hard},3{=}\text{Good},4{=}\text{Easy}}. In vocabulary flashcards, latency approximates confidence. In chess, however, latency is dominated by calculation and verification overhead, which is disciplined tournament play, not weak memory.
Penalizing think time creates timer anxiety and trains reflexive blitzing. Furthermore, FSRS maintains full mathematical predictive accuracy in binary mode (g \in {1{=}\text{Again}, 3{=}\text{Good}}).
Therefore, ChessSRS strictly uses Pure Binary FSRS:
Latency (latencyEmaMs) is preserved as optional post-hoc telemetry only, strictly firewalled from interval grading. Dead parameters (w1, w3, w15, w16) are eliminated from the kernel.
B. Graph-Aware Adaptation & Invariants
B.1 Upstream Lapse Handling
Principle: A lapse at ply N is evidence about the specific pattern-recognition trigger at that node, not proof of amnesia for ply N{+}1, N{+}2,\dots. Full subtree reset (what naive SM-2 usage would imply) is both empirically wrong (descendants are frequently still fine) and pedagogically harmful (it manufactures enormous, demotivating review backlogs from a single misclick).
We apply exponentially-decaying lapse contagion, scoped to direct descendants in the same chosen branch only (not siblings — see B.4 for sibling handling):
with defaults \lambda_0 = 0.18 (max 18% stability haircut, applied only to the immediate child), \tau = 1.5 plies (contagion is essentially gone by depth 4), \beta = 0.6 (a smaller permanent difficulty nudge, since "the line as a whole is trickier" is durable signal even after the transient stability hit is regained). Depth counts from 1 at the immediate child, so it takes the full \lambda_0; earlier the exponent read -\text{depth}/\tau, which delivered only 51.3% of the documented haircut at every depth (review-2 C2, fixed 2026-10-10). The corresponding nextDueAt is pulled forward proportionally to the same decay (not reset to "now") — this makes the child more likely to surface soon without lying about its actual measured retrievability.
This is intentionally a soft nudge, not a state-machine reset: it never touches repetitionCount or lapseCount of the child (that child was not tested), and the true FSRS update for the child still happens normally the next time it is actually reviewed.
B.2 Auto-Traversal Credit (Invariant §2.4)
Passive traversal is real exposure (the player sees the correct move played, in context, under time pressure of getting to the due node) but is not active recall — no retrieval effort was made, no error signal is possible. Per Karpicke & Roediger's testing-effect literature, re-exposure produces a small, short-lived boost, meaningfully weaker than a successful active retrieval.
Rule: auto-traversal produces a micro-stability bump with no memory-model side effects:
Applied by extending nextDueAt proportionally (never shrinking it), and explicitly:
lastReviewedAtis not updated (it is not a review),repetitionCount/lapseCount/difficultyare not touched,- capped to once per calendar day per decision (prevents a user from "farming" stability by rapidly re-opening the same line and inflating intervals),
- only applied while the item is not overdue — if a prefix move is itself already due (its own interval elapsed), auto-traversal must not silently satisfy that due-ness; the engine should surface it as its own decision point instead of just walking past it. This is enforced upstream by the traversal engine, not the scheduler, but the scheduler protects itself by refusing the exposure bump to already-
isDuestates.
B.3 Transpositions & Position Identity
Canonicalize every RepertoireDecision on FEN4 (placement, turn, castling, en-passant target) + the expected move in UCI, not on a per-chapter node id:
canonicalKey = sha1("<code class="inline-code inline-math">fen4|</code>expectedMoveUci")
- If two nodes across chapters share
canonicalKey→ they are the same memory item. Merge into a singleReviewState, referenced by both graph nodes (many-to-one edge fromRepertoireDecision.positionStateIdto a sharedPositionKnowledgeState). A review triggered from either chapter updates the one shared state; due-ness is likewise shared. - If FEN4 matches but the expected move differs across chapters → this is not a transposition, it's a repertoire contradiction (the user has committed to two different moves in the same position across two studies). This must be surfaced as a lint/validation error at import time, not silently trained — training both would actively teach interference.
- Half-move clock and full-move number are deliberately excluded from the key (they don't affect legal continuations or pattern recognition), and only FEN4 is used precisely to catch transpositions Lichess/ chess.com study trees usually fail to detect.
B.4 Interference Mitigation (sibling / confusable candidate moves)
Real interference in chess is not "same node, different candidate answers" (a RepertoireDecision has exactly one expected continuation by definition) — it's structurally distinct decisions whose surface features are highly similar (Najdorf 6.Bg5 vs 6.Be3 are different FENs entirely). Generic SRS has no notion of this at all. We add a lightweight Confusion Registry:
- Direct evidence linking: whenever an incorrect answer is recorded, compare the played move (and resulting FEN) against sibling decisions hanging off the same parent (or FEN-adjacent decisions found via a cheap structural hash of "board minus last move"). If the played move exactly matches a sibling's expected move, register a
ConfusionEdge(a, b, weight++). - Difficulty coupling: on a confirmed confusion edge, bump the sibling's difficulty by a small coupled amount too (
\Delta D_{\text{sibling}} = \kappa \cdot \Delta D_{\text{node}},\kappa \approx 0.35) — evidence that A and B are confusable is symmetric evidence, even though only A was tested. - Scheduling consequence: when the due-aware weighted-random selector (already part of your traversal engine) picks a due node with outgoing confusion edges above a weight threshold, it should interleave the confusable sibling into the same session (contrastive practice) even if the sibling isn't strictly due yet — this is the single highest-leverage, chess-specific intervention available, since discrimination training is what the literature says fixes exactly this failure mode (it is not something raw interval math can fix).
B.5 Prerequisite priors for cold-start (BKT-inspired, not BKT-replaced)
New decisions (never reviewed) get their FSRS D_0 seeded not just from the first rating but nudged by ancestor mastery:
i.e., if the parent move is currently very well retained (R_{\text{parent}} high), assume slightly lower intrinsic difficulty for the child (the player is "in flow" in this part of the tree); if the parent is fragile, assume the child is a bit harder than its first rating alone would suggest. This is a prior only — it washes out after 2–3 real reviews of the child via normal FSRS mean-reversion.
C. Recommended Model Specification — "ChessFSRS"
C.1 Retrievability (forgetting curve)
Power-law form (FSRS-5), with S in days:
chosen so that R(S, S) = 0.9 by construction (i.e. S is literally "days until 90% retention").
C.2 Interval for a target retention R_{\text{target}}
Solve R(t,S)=R_{\text{target}} for t:
C.3 Difficulty
C.4 Stability — Day-1 (cold start)
Day-1 interval = I(S_0(g), R_{\text{target}}).
C.5 Stability — successful recall (g \ge \text{Hard})
h = w_{15} if g=\text{Hard} else 1; e = w_{16} if g=\text{Easy} else 1.
C.6 Stability — lapse (g=\text{Again})
then apply the graph-layer contagion from §B.1 to descendants (this is in addition to, and downstream of, the node's own lapse update).
C.7 Same-day / rapid re-review guard
If elapsed t < t_{\text{same-day}} (default 1/24 day = 1 hour): skip the full DSR update (at t\to0, R\to1 and the formulas above degenerate/produce near-zero effective change or numerical noise). Instead apply a small linear nudge:
C.8 Recommended default parameters (chess-tuned prior)
Starting point = published FSRS-4.5 default weights, with the deltas noted (justified in §B — chess lapses should cost slightly more because of tree dependency, chess "Hard" correct answers should be weighted down slightly because hesitation strongly predicts a near-term lapse in tactical recall):
| Param | Generic FSRS default | ChessSRS default | Rationale |
|---|---|---|---|
w_0 (S0 Again) |
0.40 | 0.35 | slightly faster relapse re-surfacing |
w_1 (S0 Hard) |
0.60 | 0.55 | |
w_2 (S0 Good) |
2.40 | 2.20 | |
w_3 (S0 Easy) |
5.80 | 5.80 | unchanged |
w_4 (D0 base) |
4.93 | 4.93 | |
w_5 (D0 slope) |
0.94 | 0.94 | |
w_6 (D responsiveness) |
0.86 | 1.05 | interference/dependency makes chess errors more diagnostic of true difficulty |
w_7 (D mean reversion) |
0.01 | 0.01 | |
w_8–w_{10} |
1.49, 0.14, 0.94 | unchanged | |
w_{11}–w_{13} |
2.18, 0.05, 0.34 | unchanged | |
w_{12} (lapse severity) |
0.05 | 0.09 | tree-dependent lapses should decay stability harder |
w_{14} |
1.26 | unchanged | |
w_{15} (Hard penalty) |
0.29 | 0.22 | hesitant-but-correct chess recall is a weaker signal than in flashcards |
w_{16} (Easy bonus) |
2.61 | 2.61 | |
\lambda_0 (lapse contagion) |
— | 0.18 | new, chess-specific |
\tau (contagion depth decay) |
— | 1.5 plies | new |
\varepsilon (traversal exposure) |
— | 0.08 | new |
\kappa (confusion coupling) |
— | 0.35 | new |
These are priors, not final values. Once ~500–1000 reviews/user accumulate, run the standard FSRS optimizer (gradient descent minimizing log-loss of R predictions against observed outcomes) per-user or pooled anonymized, exactly as Anki's FSRS add-on does — the architecture below stores exactly the fields (D, S, timestamps, rating) that optimizer needs, so this is a drop-in future upgrade, not a rewrite.
C.9 Target retention & user-tunables
Exposed controls:
| Control | Range | Effect |
|---|---|---|
| Target Retention slider | 80%–95% (maintenance default 88%) | Directly plugs into R_{\text{target}} in §C.2; lower = longer intervals/less time invested, higher = shorter intervals/higher board-recall confidence |
| Tournament Mode toggle | boolean + event date | Temporarily forces R_{\text{target}}=0.95–0.97 and caps maxIntervalDays to \le days-until-event for all decisions in the selected repertoire scope; auto-reverts to base retention day after the event |
| Rigor / Safety Factor | 0.9×–1.1× | Post-multiplies the computed interval I \to I\cdot\rho; a pure "shrink my due dates a bit, I don't trust the model yet" knob independent of the retention-probability semantics |
| Interleaving intensity | off / normal / aggressive | Controls confusion-set interleaving threshold from §B.4 |
D. Dart Implementation
Pure Dart, zero dependencies, deterministic via injected now, fuzz disabled by default for testability.
import 'dart:math' as math;
// ---------------------------------------------------------------------------
// Given domain contracts (reproduced for completeness)
// ---------------------------------------------------------------------------
enum ReviewResult { correct, incorrect }
class ReviewState {
final String decisionId;
final DateTime? firstReviewedAt;
final DateTime? lastReviewedAt;
final DateTime? nextDueAt;
final int repetitionCount;
final int lapseCount;
final double stability; // stored in MILLISECONDS (existing contract)
final double difficulty; // 1..10, FSRS scale
const ReviewState({
required this.decisionId,
this.firstReviewedAt,
this.lastReviewedAt,
this.nextDueAt,
this.repetitionCount = 0,
this.lapseCount = 0,
this.stability = 0.0,
this.difficulty = 0.0,
});
static ReviewState cold(String decisionId) => ReviewState(decisionId: decisionId);
}
abstract class Scheduler {
bool isDue(ReviewState state, DateTime now);
ReviewState schedule({
required ReviewState previous,
required ReviewResult result,
required DateTime now,
});
}
// ---------------------------------------------------------------------------
// Rating (internal, reconstructed from binary correctness + latency signal)
// ---------------------------------------------------------------------------
enum Rating { again, hard, good, easy }
int _g(Rating r) => switch (r) {
Rating.again => 1,
Rating.hard => 2,
Rating.good => 3,
Rating.easy => 4,
};
Rating inferRating({
required bool correct,
bool multipleAttempts = false,
bool hintUsed = false,
int? latencyMs,
int? medianLatencyMs,
}) {
if (!correct || multipleAttempts) return Rating.again;
if (hintUsed) return Rating.hard;
if (latencyMs != null && medianLatencyMs != null && medianLatencyMs > 0) {
final ratio = latencyMs / medianLatencyMs;
if (ratio > 1.6) return Rating.hard;
if (ratio < 0.5) return Rating.easy;
}
return Rating.good;
}
// ---------------------------------------------------------------------------
// Parameters
// ---------------------------------------------------------------------------
class ChessFsrsParams {
// Initial stability per rating (days)
final double w0, w1, w2, w3;
// Difficulty
final double w4, w5, w6, w7;
// Success-stability formula
final double w8, w9, w10;
// Lapse-stability formula
final double w11, w12, w13, w14;
// Hard penalty / Easy bonus
final double w15, w16;
final double sameDayThresholdDays;
final double sameDayGainFactor;
final double sameDayLapseFactor;
final double minStabilityDays;
final double maxStabilityDays;
const ChessFsrsParams({
this.w0 = 0.35, this.w1 = 0.55, this.w2 = 2.20, this.w3 = 5.80,
this.w4 = 4.93, this.w5 = 0.94, this.w6 = 1.05, this.w7 = 0.01,
this.w8 = 1.49, this.w9 = 0.14, this.w10 = 0.94,
this.w11 = 2.18, this.w12 = 0.09, this.w13 = 0.34, this.w14 = 1.26,
this.w15 = 0.22, this.w16 = 2.61,
this.sameDayThresholdDays = 1 / 24,
this.sameDayGainFactor = 1.02,
this.sameDayLapseFactor = 0.85,
this.minStabilityDays = 0.02, // ~30 minutes floor
this.maxStabilityDays = 365 * 5,
});
const ChessFsrsParams.chessDefaults() : this();
}
// ---------------------------------------------------------------------------
// Core DSR math (pure functions, independently unit-testable)
// ---------------------------------------------------------------------------
const double kDecay = -0.5;
final double kFactor = math.pow(0.9, 1 / kDecay).toDouble() - 1; // ≈ 0.2345
double retrievability(double elapsedDays, double stabilityDays) {
if (stabilityDays <= 0) return 0.0;
final t = elapsedDays < 0 ? 0.0 : elapsedDays;
return math.pow(1 + kFactor * t / stabilityDays, kDecay).toDouble();
}
double intervalForTarget(double stabilityDays, double targetRetention) {
final r = targetRetention.clamp(0.70, 0.99);
final raw = stabilityDays / kFactor * (math.pow(r, 1 / kDecay) - 1);
return raw.isFinite && raw > 0 ? raw : 0.0;
}
double initialDifficulty(Rating rating, ChessFsrsParams p) {
final g = _g(rating);
return (p.w4 - (g - 3) * p.w5).clamp(1.0, 10.0);
}
double nextDifficulty(double d, Rating rating, ChessFsrsParams p) {
final g = _g(rating);
final delta = d - p.w6 * (g - 3);
final reverted = p.w7 * p.w4 + (1 - p.w7) * delta;
return reverted.clamp(1.0, 10.0);
}
double initialStability(Rating rating, ChessFsrsParams p) => switch (rating) {
Rating.again => p.w0,
Rating.hard => p.w1,
Rating.good => p.w2,
Rating.easy => p.w3,
};
double nextStabilitySuccess(
double d, double s, double r, Rating rating, ChessFsrsParams p,
) {
final hardPenalty = rating == Rating.hard ? p.w15 : 1.0;
final easyBonus = rating == Rating.easy ? p.w16 : 1.0;
final safeS = s <= 0 ? p.minStabilityDays : s;
final factor = math.exp(p.w8) *
(11 - d) *
math.pow(safeS, -p.w9) *
(math.exp((1 - r) * p.w10) - 1) *
hardPenalty *
easyBonus;
return safeS * (1 + factor);
}
double nextStabilityLapse(double d, double s, double r, ChessFsrsParams p) {
final safeS = s <= 0 ? p.minStabilityDays : s;
return p.w11 *
math.pow(d, -p.w12) *
(math.pow(safeS + 1, p.w13) - 1) *
math.exp((1 - r) * p.w14);
}
// ---------------------------------------------------------------------------
// Single-node scheduler — implements the required Scheduler contract
// ---------------------------------------------------------------------------
class ChessFsrsScheduler implements Scheduler {
final ChessFsrsParams params;
final double targetRetention; // user-tunable, 0.70..0.99
final double maxIntervalDays;
final double minIntervalDays;
final double fuzzFraction; // 0.0 disables fuzz (deterministic tests)
final math.Random _rng;
ChessFsrsScheduler({
this.params = const ChessFsrsParams.chessDefaults(),
this.targetRetention = 0.90,
this.maxIntervalDays = 365 * 2,
this.minIntervalDays = 1 / 1440, // 1 minute floor
this.fuzzFraction = 0.0,
int? fuzzSeed,
}) : _rng = math.Random(fuzzSeed ?? 0);
static const int _dayMs = 86400000;
@override
bool isDue(ReviewState state, DateTime now) {
if (state.nextDueAt == null) return true;
return !now.isBefore(state.nextDueAt!);
}
@override
ReviewState schedule({
required ReviewState previous,
required ReviewResult result,
required DateTime now,
}) {
return scheduleDetailed(previous: previous, result: result, now: now);
}
/// Extended entry point used by callers who have latency/hint signal.
/// `schedule()` above delegates here with conservative defaults so the
/// base `Scheduler` contract still works stand-alone.
ReviewState scheduleDetailed({
required ReviewState previous,
required ReviewResult result,
required DateTime now,
int? latencyMs,
int? medianLatencyMs,
bool hintUsed = false,
bool multipleAttempts = false,
double? retentionOverride,
}) {
final rating = result == ReviewResult.incorrect
? Rating.again
: inferRating(
correct: true,
latencyMs: latencyMs,
medianLatencyMs: medianLatencyMs,
hintUsed: hintUsed,
multipleAttempts: multipleAttempts,
);
final anchor = previous.lastReviewedAt ?? previous.firstReviewedAt;
double elapsedDays = 0;
if (anchor != null) {
final diffMs = now.difference(anchor).inMilliseconds;
elapsedDays = diffMs <= 0 ? 0.0 : diffMs / _dayMs;
}
final bool isColdStart =
previous.repetitionCount == 0 && previous.stability <= 0;
double newDifficulty;
double newStabilityDays;
if (isColdStart) {
newDifficulty = initialDifficulty(rating, params);
newStabilityDays = initialStability(rating, params);
} else {
final prevStabilityDays =
previous.stability <= 0 ? params.minStabilityDays : previous.stability / _dayMs;
final prevDifficulty =
previous.difficulty <= 0 ? initialDifficulty(Rating.good, params) : previous.difficulty;
final r = retrievability(elapsedDays, prevStabilityDays);
newDifficulty = nextDifficulty(prevDifficulty, rating, params);
if (elapsedDays < params.sameDayThresholdDays) {
newStabilityDays = rating == Rating.again
? prevStabilityDays * params.sameDayLapseFactor
: prevStabilityDays * params.sameDayGainFactor;
} else if (rating == Rating.again) {
newStabilityDays = nextStabilityLapse(newDifficulty, prevStabilityDays, r, params);
} else {
newStabilityDays =
nextStabilitySuccess(newDifficulty, prevStabilityDays, r, rating, params);
}
}
newStabilityDays =
newStabilityDays.clamp(params.minStabilityDays, params.maxStabilityDays);
final effectiveRetention = (retentionOverride ?? targetRetention).clamp(0.70, 0.99);
double intervalDays = intervalForTarget(newStabilityDays, effectiveRetention);
intervalDays = _applyFuzz(intervalDays, previous.decisionId);
intervalDays = intervalDays.clamp(minIntervalDays, maxIntervalDays);
final nextDue = now.add(Duration(milliseconds: (intervalDays * _dayMs).round()));
return ReviewState(
decisionId: previous.decisionId,
firstReviewedAt: previous.firstReviewedAt ?? now,
lastReviewedAt: now,
nextDueAt: nextDue,
repetitionCount:
rating == Rating.again ? previous.repetitionCount : previous.repetitionCount + 1,
lapseCount: rating == Rating.again ? previous.lapseCount + 1 : previous.lapseCount,
stability: newStabilityDays * _dayMs,
difficulty: newDifficulty,
);
}
double _applyFuzz(double intervalDays, String seedKey) {
if (fuzzFraction <= 0) return intervalDays;
final jitter = (_rng.nextDouble() * 2 - 1) * fuzzFraction;
return intervalDays * (1 + jitter);
}
}
// ---------------------------------------------------------------------------
// Graph layer — lapse contagion, auto-traversal credit, transposition merge,
// confusion coupling. This layer needs neighbor/repository context that the
// two-argument Scheduler.schedule() cannot express, so it is deliberately a
// separate coordinator built ON TOP OF ChessFsrsScheduler rather than an
// alternate Scheduler implementation.
// ---------------------------------------------------------------------------
class GraphNode {
final String decisionId;
final String? parentId;
final String fen4;
final String expectedMoveUci;
const GraphNode({
required this.decisionId,
required this.parentId,
required this.fen4,
required this.expectedMoveUci,
});
}
abstract class ReviewStateRepository {
ReviewState? get(String decisionId);
void put(String decisionId, ReviewState state);
List<String> childrenOf(String decisionId);
/// Returns an existing canonical id sharing (fen4, expectedMoveUci), or
/// null if this is a brand-new memory item (§B.3).
String? canonicalIdFor(String fen4, String expectedMoveUci);
}
class GraphAwareParams {
final double contagionBase; // lambda0
final double contagionTau; // depth decay
final int maxContagionDepth;
final double contagionDifficultyBump; // beta
final double exposureStabilityGain; // epsilon
final double confusionDifficultyCoupling; // kappa
const GraphAwareParams({
this.contagionBase = 0.18,
this.contagionTau = 1.5,
this.maxContagionDepth = 3,
this.contagionDifficultyBump = 0.6,
this.exposureStabilityGain = 0.08,
this.confusionDifficultyCoupling = 0.35,
});
}
class GraphAwareReviewCoordinator {
final ChessFsrsScheduler scheduler;
final ReviewStateRepository repo;
final GraphAwareParams gParams;
GraphAwareReviewCoordinator(
this.scheduler,
this.repo, {
this.gParams = const GraphAwareParams(),
});
/// Records a genuine active-recall attempt at [node].
ReviewState recordActiveReview({
required GraphNode node,
required ReviewResult result,
required DateTime now,
int? latencyMs,
int? medianLatencyMs,
bool hintUsed = false,
String? playedMoveUci, // for confusion detection
List<GraphNode> siblings = const [],
}) {
final canonicalId =
repo.canonicalIdFor(node.fen4, node.expectedMoveUci) ?? node.decisionId;
final previous = repo.get(canonicalId) ?? ReviewState.cold(canonicalId);
final updated = scheduler.scheduleDetailed(
previous: previous,
result: result,
now: now,
latencyMs: latencyMs,
medianLatencyMs: medianLatencyMs,
hintUsed: hintUsed,
);
repo.put(canonicalId, updated);
if (result == ReviewResult.incorrect) {
_propagateLapseContagion(node.decisionId, now, depth: 1);
if (playedMoveUci != null) {
_coupleConfusableSiblings(playedMoveUci, siblings, now);
}
}
return updated;
}
/// Records a NON-tested pass-through move (Invariant §2.4). Never touches
/// repetitionCount/lapseCount/difficulty/lastReviewedAt.
ReviewState recordAutoTraversalExposure({
required GraphNode node,
required DateTime now,
}) {
final canonicalId =
repo.canonicalIdFor(node.fen4, node.expectedMoveUci) ?? node.decisionId;
final previous = repo.get(canonicalId);
if (previous == null || previous.stability <= 0) {
return previous ?? ReviewState.cold(canonicalId);
}
// Never grant exposure credit to an item that is already due — the
// traversal engine should have stopped and tested it instead.
if (scheduler.isDue(previous, now)) return previous;
if (previous.lastReviewedAt != null &&
_isSameCalendarDay(previous.lastReviewedAt!, now)) {
return previous; // once/day throttle
}
final boostedStability = previous.stability * (1 + gParams.exposureStabilityGain);
final currentDue = previous.nextDueAt ?? now;
final remainingMs = currentDue.difference(now).inMilliseconds;
final extendedDue = remainingMs > 0
? currentDue.add(Duration(
milliseconds: (remainingMs * gParams.exposureStabilityGain).round()))
: currentDue;
final updated = ReviewState(
decisionId: previous.decisionId,
firstReviewedAt: previous.firstReviewedAt,
lastReviewedAt: previous.lastReviewedAt, // deliberately unchanged
nextDueAt: extendedDue,
repetitionCount: previous.repetitionCount,
lapseCount: previous.lapseCount,
stability: boostedStability,
difficulty: previous.difficulty,
);
repo.put(canonicalId, updated);
return updated;
}
void _propagateLapseContagion(String parentId, DateTime now, {required int depth}) {
if (depth > gParams.maxContagionDepth) return;
final decay = gParams.contagionBase * math.exp(-depth / gParams.contagionTau);
for (final childId in repo.childrenOf(parentId)) {
final childState = repo.get(childId);
if (childState == null || childState.stability <= 0) continue;
final shrunkStability = childState.stability * (1 - decay).clamp(0.0, 1.0);
final shrunkDue = _pullDueForward(childState, decay, now);
final bumpedDifficulty =
(childState.difficulty + gParams.contagionDifficultyBump * decay).clamp(1.0, 10.0);
repo.put(childId, ReviewState(
decisionId: childState.decisionId,
firstReviewedAt: childState.firstReviewedAt,
lastReviewedAt: childState.lastReviewedAt,
nextDueAt: shrunkDue,
repetitionCount: childState.repetitionCount,
lapseCount: childState.lapseCount,
stability: shrunkStability,
difficulty: bumpedDifficulty,
));
_propagateLapseContagion(childId, now, depth: depth + 1);
}
}
DateTime? _pullDueForward(ReviewState s, double decay, DateTime now) {
if (s.nextDueAt == null) return null;
final remaining = s.nextDueAt!.difference(now);
if (remaining.isNegative) return s.nextDueAt; // already due; leave as-is
final newRemainingMs = (remaining.inMilliseconds * (1 - decay)).round();
return now.add(Duration(milliseconds: newRemainingMs));
}
void _coupleConfusableSiblings(
String playedMoveUci, List<GraphNode> siblings, DateTime now,
) {
for (final sib in siblings) {
if (sib.expectedMoveUci != playedMoveUci) continue;
final sibState = repo.get(sib.decisionId);
if (sibState == null) continue;
final bumped = (sibState.difficulty +
gParams.confusionDifficultyCoupling)
.clamp(1.0, 10.0);
repo.put(sib.decisionId, ReviewState(
decisionId: sibState.decisionId,
firstReviewedAt: sibState.firstReviewedAt,
lastReviewedAt: sibState.lastReviewedAt,
nextDueAt: sibState.nextDueAt,
repetitionCount: sibState.repetitionCount,
lapseCount: sibState.lapseCount,
stability: sibState.stability,
difficulty: bumped,
));
}
}
bool _isSameCalendarDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
}
Edge cases explicitly handled
| Case | Handling |
|---|---|
| Zero/cold stability | isColdStart branch bypasses retrievability math entirely (undefined at S=0); repository get() returning null is normalized to ReviewState.cold(id) |
| Rapid consecutive reviews (same session re-test) | sameDayThresholdDays branch replaces the full DSR update with a damped linear nudge, avoiding division blow-ups as t\to0 |
| Overdue review (elapsed ≫ scheduled interval) | Retrievability is computed from actual elapsed days, not the planned interval, so an overdue lapse correctly reflects a much lower R and produces a larger, honest stability drop — no special-casing needed, it falls out of the math |
| Auto-traversal on an already-due prefix | Explicitly refused (scheduler.isDue(previous, now) guard) so passive credit can never mask a due active-recall obligation |
| Transposition merge collision | canonicalIdFor is consulted before every read/write in the coordinator, so both chapters always resolve to one shared ReviewState |
| Negative/garbage elapsed time (clock skew, imported data) | Clamped to 0 before use in both the kernel and the coordinator |
Implementation Architecture Review
Algorithm & Design Correctness Review — #1 of 4
Target: ChessFsrsScheduler and the FSRS math layer — lib/src/domain/chess_fsrs_scheduler.dart
(308 LOC), its state carrier lib/src/domain/review_state.dart, the two sibling schedulers in
lib/src/domain/scheduler.dart, and the SQLite round-trip in lib/src/persistence/.
Reference spec: ChessSRS Scheduling Architecture.md §C (C.1–C.9).
No code was modified in this pass.
0. Verification status (read this first)
The Dart/Flutter SDK is not available in this sandbox and cannot be installed: storage.googleapis.com,
dl.google.com, api.dart.dev and pub.dev all returned 000 (unreachable), and the GitHub release
asset for the SDK returned 404. So I could not execute flutter test.
To avoid hand-waving the arithmetic, I wrote a throwaway line-by-line port of
chess_fsrs_scheduler.dart and validated it by re-deriving all 26 assertions in
test/domain/chess_fsrs_scheduler_test.dart. All 26 reproduce (e.g. R(10,10)=0.9,
I(10,0.90)=10.0, cold-start S=190080000.0 ms, interval 2.7323 d). Every number below comes from
that validated port.
That port is deliberately not committed. A parallel Python reimplementation of the scheduler would
drift from the authoritative Dart one and become its own liability. To confirm any trace below, run the
real suite — flutter test test/domain/chess_fsrs_scheduler_test.dart — and add a case for the scenario
in question; the port only existed to make the review tractable without an SDK.
One caveat earned the hard way: my first port used Python or where Dart has ??, which treats
epoch 0.0 as null and silently corrupted an early trace. It is fixed; the numbers below are post-fix.
All static claims (line numbers, call-graph, greps) come from tool calls, not memory.
1. Summary
Correct but fragile, with one high-severity behavioural bug and one high-severity dead-code path.
The core DSR mathematics are a faithful implementation of the spec. I verified the two properties that are easiest to get wrong and both hold exactly:
R(S,S) = 0.9by construction —fsrsRetrievability(10,10)returns exactly0.9, andkFsrsFactorcomputes to0.23456790123456783vs.19/81 = 0.2345679012345679(§C.1).I(S, 0.90) = S—fsrsIntervalForTarget(10, 0.90)returns exactly10.0(§C.2).
Difficulty uses the new D' in both stability formulas, which matches canonical FSRS (many
implementations get this backwards). The interval ladder is monotonic and the preview function matches
schedule() to 0.000000 d drift for its first 6 steps. schedule() is O(1) time and allocation-free
beyond the returned state.
The problems are not in the formulas. They are in what the formulas are fed and which parts of the spec are actually reachable.
2. Confirmed issues
C1 — The same-day guard protects stability but not difficulty. Three failures in one session pin D at 10 and cut stability growth ~6×. (High)
Where: chess_fsrs_scheduler.dart:234 applies fsrsNextDifficulty unconditionally, before the
same-day branch at :236.
Spec: §C.7 — "If elapsed t < t_same-day: skip the full DSR update. Instead apply a small linear nudge: S' = S × {1.02 if g≥Good; 0.85 if g=Again}." The difficulty update is part of the DSR update the spec says to skip. The code skips only the stability half.
Reachability (verified, three separate checks):
review_session.dart:411-416— on an incorrect move the decision is unconditionally re-queued. No attempt cap exists anywhere in the file.review_session.dart:642—_advanceToNextDuepops with_dueQueue.removeAt(0)and applies noisDueAtcheck, so the re-queued item is re-presented in the same session even though the lapse just pushed itsnextDueAt~10 days out.- Therefore N lapses on one item in one session is user-reachable.
Traced example — mature item, S=100 d, D=4.93, fails then passes, 30 s apart, five times:
| # | result | D | S (days) | next interval (d) |
|---|---|---|---|---|
| 1 | incorrect | 7.009 | 7.8911 | 9.80 |
| correct | 6.988 | 8.0490 | 10.00 | |
| 2 | incorrect | 9.047 | 6.8416 | 8.50 |
| correct | 9.005 | 6.9785 | 8.67 | |
| 3 | incorrect | 10.000 | 5.9317 | 7.37 |
| correct | 9.949 | 6.0503 | 7.51 | |
| 5 | correct | 9.949 | 4.5480 | 5.65 |
D saturates at the clamp(1.0, 10.0) ceiling after three failures. The cost is in
fsrsNextStabilitySuccess's (11 - d) term (:161):
| D | (11−D) | growth vs. D=4.93 |
|---|---|---|
| 4.93 | 6.07 | 1.000× |
| 7.009 | 3.99 | 0.657× |
| 8.87 | 2.13 | 0.351× |
| 10.00 | 1.00 | 0.165× |
Recovery is near-geological: mean reversion is w7 = 0.01 per review toward 4.93, so it takes
390 consecutive successful reviews to get D back within 0.1 of 4.93. One bad practice session
on a Tuesday suppresses that move's interval growth sixfold for the rest of the user's life with the app.
Expected: §C.7 — difficulty frozen during rapid re-review, so an intra-session burst behaves as one learning event. Actual: difficulty ratchets on every rapid failure; stability does not.
C2 — Decision D015's hintUsed / multipleAttempts rule is unreachable in production. (High, spec fidelity)
Where: chess_fsrs_scheduler.dart:202-206.
Three independent facts, each verified:
- The
Schedulerinterface (scheduler.dart:20-24) declaresschedule({previous, result, now})— nohintUsed/multipleAttempts. The extra parameters exist only on the concrete class, so they are invisible through the interface. grep -rn "scheduler.schedule(" lib/returns exactly one hit:graph_aware_review_coordinator.dart:191, which holds the scheduler as the abstractSchedulertype (:148) and passes only three arguments. It cannot pass the flags.grep -rni hint lib/finds no hint feature inlib/src/domain/review/,lib/src/review/, orlib/src/view/review/— onlyhintTexton design-system text fields and this scheduler's own doc comment.
The concrete deviation. D015 defines Again as "incorrect move, hint used, or corrected
false-start attempt." Trace what a corrected false-start actually produces:
- User plays a wrong move →
submitMoveincorrect branch → lapse recorded (Again), decision re-queued. - User then answers correctly when it comes back round →
submitMovecorrect branch →Good, sorepetitionCount += 1(:267-269) andstability ×= 1.02.
Net effect of one corrected false-start: two events (Again then Good), not one Again, and the
item's success counter goes up. (Note retryMove at review_session.dart:437-478 takes the other
route — it writes no SRS state at all, so that path does end as a single Again. Two UI paths through
the same user action, with different scheduling outcomes.)
Expected: one Again; repetitionCount unchanged.
Actual: Again + Good; repetitionCount incremented; stability nudged back up 2%.
This is precisely what the unreachable multipleAttempts flag was built to express. Today it is
exercised only by chess_fsrs_scheduler_test.dart:113-121, which constructs the scheduler directly —
so the test passes while the production behaviour differs from what it asserts.
C3 — fsrsIntervalProgressionPreview diverges from schedule() once the interval cap binds. (Low–Medium)
Where: chess_fsrs_scheduler.dart:291-308. The loop never applies
clamp(minIntervalDays, maxIntervalDays) (:246) or the stability clamp (:247).
| step | preview (d) | schedule() (d) |
|---|---|---|
| 0–5 | 2.7, 10.6, 35.9, 107.9, 293.5, 732.8 | identical (0.000000 drift) |
| 6 | 1697.4 | 1095.0 |
| 9 | 14724.7 | 1095.0 |
At step 9 the preview over-promises by 13.4×.
Severity is currently Low because grep -rn fsrsIntervalProgressionPreview lib/ finds no caller
outside its own declaration — it is only used by the test. It is a trap for whoever wires it to a
settings screen, which is the obvious next step given §C.9's "Target Retention slider".
C4 — Switching schedulerType silently reinterprets ReviewState.stability. (Medium)
Where: review_service.dart:26-28 selects among three schedulers from a user setting;
study_preferences.dart:177 defaults to SchedulerType.simple;
srs_settings_screen.dart:211-214 exposes FSRS/Simple/EaseScaling as a segmented control.
All three write different semantics into the same ReviewState.stability field with no unit tag and no
migration:
| scheduler | writes (scheduler.dart:94,180; chess_fsrs_scheduler.dart:271) |
reads it back as |
|---|---|---|
SimpleScheduler |
current interval, ms | current interval, ms (:82) |
EaseScalingScheduler |
current interval, ms | current interval, ms (:170) |
ChessFsrsScheduler |
days-to-90%-retention, ms | stability, days (:227) |
Traced example: an item that SimpleScheduler had at a 30-day interval (stability = 2592000000,
repetitionCount = 4, due now). Flip the setting to FSRS and answer correctly:
isColdStart=false(:217), so it is treated as mature memory.difficulty = 0.0→ falls back toD0(good) = 4.93(:229-231).- FSRS computes
S' = 79.47 d→ next interval 98.69 days, where Simple would have given 60.
A 30-day item silently disappears for three months because the user tapped a different algorithm name. The reverse direction (FSRS→Simple) reads a 30-day stability as a 30-day interval and emits 60 d, where FSRS-correct would be 37.3 d.
Compounding factor: review_state.dart:12 documents the field as "current interval in days for
SimpleScheduler" — but all three schedulers write milliseconds. The doc is wrong, which is
exactly the kind of thing that lets a unit collision survive review. position_knowledge_state.dart:74
gets it right ("Memory stability in milliseconds"), so the two state classes document the same shared
field two different ways.
C5 — A lapse can increase stability; the invariant is assumed but not enforced. (Low)
Where: fsrsNextStabilityLapse, :167-171. w11 · D^-w12 · ((S+1)^w13 − 1) · e^((1−R)w14) has no
monotonic clamp against S.
126 of 210 sampled (S, D, R) combinations raise stability. The in-app reachable path:
- Cold-start
Again→S = w0 = 0.35 d,D = 6.81, due in 10.4 h. - Come back 5 days later and fail again →
S = 0.350 → 0.371 d(+5.9%),R = 0.479. - Come back 30 days later →
S = 0.350 → 0.516 d(+47%),R = 0.218.
Failing an item makes it stickier and pushes its due date out. This is shared with canonical FSRS
(which also does not clamp), so it is arguably inherited rather than introduced — but the function's own
doc says "Calculates regressed stability after a lapse" and
chess_fsrs_scheduler_test.dart:95 asserts lessThan(s1.stability). The invariant is documented and
tested in the common case only. Absolute values are small, hence Low.
C6 — isColdStart never re-arms; a failed first attempt permanently demotes the item. (Low)
Where: :217 — previous.repetitionCount == 0 && previous.stability <= 0.
After a failed cold start, repetitionCount stays 0 (:268) but stability becomes
0.35 d > 0, so the guard is false forever. Every later review takes the full DSR path treating
S = 0.35 d as earned memory.
Traced: next correct answer gives S = 1.254 d, versus 8.536 d for a clean cold start — a
6.8× gap that never closes.
Mitigating: since hintUsed is unreachable (C2), only a genuine first-attempt incorrect triggers
this today, which is arguably intended. It becomes a live bug the moment C2 is fixed.
C7 — difficulty has three disagreeing sources of truth. (Low)
| source | value |
|---|---|
review_state.dart:26 (ReviewState default) |
0.0 |
srs_schema.dart:101 (srs_review_state column DEFAULT) |
5.0 |
sqlite_study_repository.dart:710 and :975 (NULL fallback) |
5.0 |
chess_fsrs_scheduler.dart:229-231 (fallback when <= 0) |
4.93 (D0(good)) |
A cold item built in memory gets D = 4.93; one read from a default-constructed row gets D = 5.0.
Impact is small but real and permanent: (11−D) is 6.070 vs 6.000, a −1.15% growth-rate
difference that compounds over the item's whole life. Note position_knowledge_state's column
(srs_schema.dart:82) defaults to 0.0 while the review-state column defaults to 5.0 — the two
tables holding the same logical field disagree with each other too.
3. Design concerns
D1 — (11 − d) is unguarded in a public function. fsrsNextStabilitySuccess (:159) is a public
top-level function with no clamp on d. At d > 11 the factor goes negative and the function returns
negative stability. schedule() is saved only by the clamp(minStabilityDays, maxStabilityDays) at
:247; any future caller of the raw function must re-clamp. The other public helpers
(fsrsRetrievability, fsrsInitialDifficulty, fsrsNextDifficulty) do guard their inputs.
D2 — steps is unvalidated. fsrsIntervalProgressionPreview (:293) accepts any int; steps = 0
and -1 both return [] silently, and the loop is O(steps) with no upper bound. Fine for a test, a
hang if it is ever bound to a text field.
D3 — O(k) sequential awaited inserts per review answer. RETRACTED — see correction below.
sqlite_study_repository.dart:513-538: savePositionKnowledgeState issues one awaited, unbatched
saveReviewState per decision matching the canonical id, plus one more per row in a loop. For a
position that transposes across many chapters this is k sequential round-trips on the review hot path.
A batched variant exists 60 lines below (:596-618), so the single-item path is the outlier, not the
house style.
Correction (added 2026-10-08, after grepping for callers). The premise — "on the review hot
path" — is false, and with it the severity. savePositionKnowledgeState has zero production
callers; the only references outside its own definition are the interface declaration at
study_repository.dart:77 and five call sites in tests. savePositionKnowledgeStates (the batched
variant) has no callers at all, production or test.
The path that actually runs on a review answer is saveAnswerBatch, called from
review_service.dart:317 and :355. It already uses _db.batch() and mirrors to
kTableSrsReviewState with a single INSERT … SELECT … WHERE canonicalStateId = ? rather than a
per-decision loop — i.e. it already does what this entry recommended.
What remains is not a performance issue but a dead-code one: two public repository methods and
their five test call sites exist solely to be tested. The unbatched loop in the singular method is
still a latent trap for whoever first wires it up in production, since it would reintroduce the O(k)
round-trips on the very path saveAnswerBatch was written to avoid. Either wire the singular method
through the batch, or delete both and let saveAnswerBatch be the only writer. Improvement 7 below
is void as originally written.
D4 — Timestamps are persisted as offset-less local time.
sqlite_study_repository.dart:517-519 write toIso8601String() on local DateTimes (no Z, no
offset), and :704-706 read them back with DateTime.parse, which yields local. Round-trips are
consistent within a timezone; a device timezone change reinterprets every stored nextDueAt as
wall-clock in the new zone, shifting all due dates by the offset delta. For an SRS app whose entire
output is a due date, this deserves a deliberate decision.
D5 — repetitionCount means different things per scheduler, but isLearned reads it uniformly.
FSRS never resets it on lapse (:267-269); SimpleScheduler and EaseScalingScheduler both reset to
0 (scheduler.dart:102,186). Meanwhile ReviewState.isLearned (:60) is just repetitionCount > 0
for all of them, and combined with C6 an item can be neither isNew (:59) nor isLearned
after a failed first attempt. The field doc concedes "semantics depend on scheduler" — which is an
acknowledgement that the derived predicates built on it cannot be trusted.
D6 — "FSRS-5" in the name, FSRS-4.5 in the math. The class doc (:172-176) says
"Domain-adapted FSRS-5". §C.3 and the code implement
clip(w7·w4 + (1−w7)(D − w6(g−3)), 1, 10) — the FSRS-4.5 difficulty form, without the linear damping
FSRS-5 added. Separately, §C.8's parameter table still lists w15 (Hard penalty) and w16 (Easy bonus)
that the binary ChessFsrsParams does not carry at all. The spec table predates Decision D015 and was
never reconciled. Cosmetic, but it will mislead anyone trying to port FSRS's published optimizer onto
this — which §C.8 explicitly proposes doing.
D7 — maxStabilityDays and maxIntervalDays are unlinked. 1825 d vs 1095 d (:43, :180).
Once the interval cap binds, I(1825, 0.88) = 2266.6 d is clamped to 1095 d, so the item is actually
reviewed at R = 0.93628 rather than the 0.88 target. That inflated R is then fed back into
fsrsNextStabilitySuccess, systematically under-rewarding the most mature items. Self-consistent, but
the target-retention contract quietly stops holding for exactly the items the user has mastered best.
(Symmetrically, minIntervalDays = 1/1440 can never bind: minStabilityDays = 0.02 d already implies a
minimum interval of 35.77 min.)
4. Suggested improvements (priority order)
Each is scoped to be reviewable on its own.
- Gate difficulty on the same-day guard (C1). Move
:234inside theelseof the same-day branch at:236, sonewDifficulty = prevDifficultywhenelapsedDays < sameDayThresholdDays. ~5 lines, brings the code in line with §C.7 as written. Add a regression test: 3 intra-session failures must leaveDat its pre-session value. Highest value-to-risk ratio in this file. - Resolve the D015 gap (C2). Either (a) widen the
Scheduler.schedulecontract to carryhintUsed/multipleAttemptsand pass them fromgraph_aware_review_coordinator.dart:191, or (b) delete the dead parameters and record a corrected false-start as a singleAgain— which means making thesubmitMovecorrect branch aware that this decision already lapsed this session. Option (b) also fixes the two-paths-one-action inconsistency betweensubmitMoveandretryMove. - Tag the state with its producing scheduler (C4). Add a
schedulerKind(orstabilityUnit) field toReviewState, persist it, and on mismatch either migrate or reset to cold-start. Cheapest immediate win: fix thereview_state.dart:12doc comment, which currently states the wrong unit. - Make the preview honest (C3). Route it through the same clamps as
schedule(), or delete it until something calls it. It has no caller today, so deletion is free. - Decide the lapse-monotonicity question (C5). Either add
math.min(newS, S)to:170, or document that non-monotonicity is intentional FSRS inheritance and add a test pinning theS=0.35, 5 d overduecase so a future weight retune cannot silently make it worse. - Collapse the difficulty defaults to one named constant (C7). Export
kDefaultDifficulty = 4.93(or0.0+ universal fallback) and use it in the schema, both row mappers, and:229. Batch the per-decision writes (D3).Void — see the D3 retraction. The production write path (saveAnswerBatch,review_service.dart:317/:355) is already batched. The real finding is thatsavePositionKnowledgeState/savePositionKnowledgeStatesare dead code reachable only from tests, and the singular one still contains the unbatched loop. Decide between routing it through the batch and deleting both.- Store timestamps as UTC or epoch-ms (D4). Mechanical, but it is a schema change, so it wants its own migration and should not be bundled with anything above.
Items 1, 4, 6 are localised and low-risk. Items 2, 3, 8 touch persisted data or the public
Scheduler contract and want a design note first.
5. Not reviewed here
Deferred to the remaining three passes, though several surfaced incidentally above and are flagged so they are not lost:
- Auto-traversal exposure credit compounds without any recall test.
graph_aware_review_coordinator.dart:235multiplies stability by1.08once per calendar day, with only a same-day throttle and a "not already due" guard (:224). An item merely passed over while drilling other lines can reach1.08^30 = 10.06×stability in a month, never having been actively recalled. Needs a proper look in pass #2. - Repeated failures re-fire graph side-effects. Each
incorrectre-triggers lapse contagion and sibling coupling (:196-203), so C1's three-failure session also applies contagion three times. Pass #2. review_session.dartstate machine (970 LOC) — thesubmitMove/retryMovedivergence in C2 is a symptom of something larger there. Pass #3.review_service.dart/review_controller.dart(688 + 1508 LOC) — idempotency, double-scheduling, and clock injection under concurrency. Pass #4.
6. Resolution log
Every item above has now been dispositioned. This is the audit trail for why each one was fixed, documented, or dropped, so a later reader does not have to re-derive the reasoning.
| # | Finding | Disposition | Why |
|---|---|---|---|
| C1 | Difficulty mutated during rapid re-review | Fixed 18dde77 |
Contradicted §C.7 and changed scheduling output. Two regression tests, one with fail-to-pass evidence against base. |
| C2 | Corrected false-start graded twice | Fixed 604d5a1 |
Same human action had two outcomes depending on which button ended the turn. Fixed at the session layer; one test removed with an Ack-G08: trailer. |
| C3 | Interval preview ignored the scheduler's clamps | Fixed 5ccb7ba |
Advertised 1697 d where the scheduler emits 1095 d. Preview now clamps in the same order; tolerance measured, not guessed. |
| C4 | Cross-scheduler stability reinterpretation |
Deferred, documented | See note below. Every available remedy mutates user memory. |
| C5 | Lapse formula not monotone in S | Documented + pinned cbf3194 |
Matches canonical FSRS and §C.6; adding min(result, s) would be a spec change. Numbers pinned so a weight retune cannot silently widen the window. |
| C6 | isColdStart never re-arms |
Open | Genuine 6.8× gap but needs a product answer on what "forgotten" means. Low blast radius in practice. |
| C7 | Three disagreeing difficulty defaults | Dropped | Both columns are NOT NULL and the repository always writes explicitly, so the ?? 5.0 fallbacks are unreachable. Dead magic numbers, not a live bug. |
| D1 | steps unvalidated |
Fixed 5ccb7ba |
Folded into the C3 fix. |
| D2 | stability doc said "days" |
Fixed 5ccb7ba |
The comment is what let C4 survive review. Now states milliseconds and names each scheduler's meaning. |
| D3 | Sequential per-decision writes | Retracted | Premise false — no production callers; the real path is already batched. Real finding is dead code. See the D3 correction. |
| D4 | Offset-less local timestamps | Open | Real portability bug, but it is a schema migration with data risk and deserves its own PR. |
| D5–D7 | Semantics / naming / cap-linking | Open | Recorded; none is a live defect today. |
Why C4 was deferred rather than fixed
The collision is real and demonstrable: a 30-day interval written by SimpleScheduler and later read
by ChessFsrsScheduler yields 98.69 d instead of the expected 60 d, because the two schedulers store
different quantities in the same column. A fix requires a schema migration to record which scheduler
produced a row — that part is mechanical (database.dart is at version: 15 with an onUpgrade
handler) — and then a policy decision about what happens to memory already on disk when someone
changes the algorithm setting:
- Reset to cold start. Honest and simple, but destroys every accumulated interval the moment a user taps a different algorithm name.
- Convert. A principled mapping exists (
I(S, 0.88) = S × 1.242, soS = interval / 1.242), but it is approximate:SimpleScheduler's ladder was not produced by the FSRS curve, so the conversion invents a stability the user never earned. - Warn and require confirmation. Least destructive; leaves the data intact but makes the reinterpretation the user's informed choice.
All three mutate or expose user memory differently, and the choice is a product decision rather than
an engineering one. Option 1 is the only one that can be ruled out on technical grounds, since it
punishes curiosity with irreversible data loss. Recommendation: option 3 now, option 2 later if
switching turns out to be common. Until then the gap is documented in review_state.dart at the
field that causes it, which is where a future reader will look.
Note on process
Two of the corrections above (D3 retracted, C7 dropped) were found by checking the callers of the code the review described, not the code itself. Both original findings read the implementation correctly and still reached a wrong conclusion about impact, because neither asked whether the path was reachable in production. Passes #2–#4 should check reachability before assigning severity.