Skip to main content
Home / Documentation / FSRS Spaced Repetition for Chess
Memory Model

FSRS Spaced Repetition for Chess

The mathematical formulation of FSRS-4.5/5 DSR power-law forgetting curves adapted for chess opening recall.

App version: v1.4.0
Licence: GPL-3.0

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:

g = \begin{cases} \text{Again (1)} & \text{incorrect move, hint used, or corrected false start}\\ \text{Good (3)} & \text{first committed move is correct (unlimited think time)} \end{cases}

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):

S_{\text{child}}' = S_{\text{child}} \times \bigl(1 - \lambda_0 \cdot e^{-(\text{depth}-1)/\tau}\bigr)
D_{\text{child}}' = \operatorname{clip}\bigl(D_{\text{child}} + \beta \cdot \lambda_0 \cdot e^{-(\text{depth}-1)/\tau},\ 1,\ 10\bigr)

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:

S' = S \times (1 + \varepsilon), \qquad \varepsilon = 0.08

Applied by extending nextDueAt proportionally (never shrinking it), and explicitly:

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")

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:

  1. 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++).
  2. 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.
  3. 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:

D_0' = \operatorname{clip}\Bigl(D_0(g) - \gamma \cdot (R_{\text{parent}} - 0.9),\ 1,\ 10\Bigr), \quad \gamma \approx 2.0

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.1 Retrievability (forgetting curve)

Power-law form (FSRS-5), with S in days:

R(t, S) = \left(1 + F\cdot\frac{t}{S}\right)^{C}, \qquad C = -0.5,\quad F = 0.9^{1/C}-1 = \tfrac{19}{81}\approx 0.2345

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:

I(S, R_{\text{target}}) = \frac{S}{F}\Bigl(R_{\text{target}}^{\,1/C} - 1\Bigr) = \frac{81\,S}{19}\Bigl(R_{\text{target}}^{-2}-1\Bigr)

C.3 Difficulty

D_0(g) = \operatorname{clip}\bigl(w_4 - (g-3)w_5,\ 1,\ 10\bigr)
D'(D,g) = \operatorname{clip}\Bigl(w_7\cdot w_4 + (1-w_7)\bigl(D - w_6(g-3)\bigr),\ 1,\ 10\Bigr)

C.4 Stability — Day-1 (cold start)

S_0(g) = w_0,\ w_1,\ w_2,\ w_3 \quad\text{for } g=\text{Again, Hard, Good, Easy}

Day-1 interval = I(S_0(g), R_{\text{target}}).

C.5 Stability — successful recall (g \ge \text{Hard})

S' = S\left(1 + e^{w_8}\cdot(11-D)\cdot S^{-w_9}\cdot\bigl(e^{(1-R)w_{10}}-1\bigr)\cdot h\cdot e\right)

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})

S'_{\text{lapse}} = w_{11}\cdot D^{-w_{12}}\cdot\bigl((S+1)^{w_{13}}-1\bigr)\cdot e^{(1-R)w_{14}}

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:

S' = S \times \begin{cases} 1.02 & g \ge \text{Good} \\ 0.85 & g = \text{Again}\end{cases}

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

R_{\text{target}} = \text{clip}(R_{\text{base}} + \Delta_{\text{tournament}},\ 0.70,\ 0.99)

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:

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):

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:

  1. The Scheduler interface (scheduler.dart:20-24) declares schedule({previous, result, now}) — no hintUsed/multipleAttempts. The extra parameters exist only on the concrete class, so they are invisible through the interface.
  2. grep -rn "scheduler.schedule(" lib/ returns exactly one hit: graph_aware_review_coordinator.dart:191, which holds the scheduler as the abstract Scheduler type (:148) and passes only three arguments. It cannot pass the flags.
  3. grep -rni hint lib/ finds no hint feature in lib/src/domain/review/, lib/src/review/, or lib/src/view/review/ — only hintText on 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:

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:

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:

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.

  1. Gate difficulty on the same-day guard (C1). Move :234 inside the else of the same-day branch at :236, so newDifficulty = prevDifficulty when elapsedDays < sameDayThresholdDays. ~5 lines, brings the code in line with §C.7 as written. Add a regression test: 3 intra-session failures must leave D at its pre-session value. Highest value-to-risk ratio in this file.
  2. Resolve the D015 gap (C2). Either (a) widen the Scheduler.schedule contract to carry hintUsed/multipleAttempts and pass them from graph_aware_review_coordinator.dart:191, or (b) delete the dead parameters and record a corrected false-start as a single Again — which means making the submitMove correct branch aware that this decision already lapsed this session. Option (b) also fixes the two-paths-one-action inconsistency between submitMove and retryMove.
  3. Tag the state with its producing scheduler (C4). Add a schedulerKind (or stabilityUnit) field to ReviewState, persist it, and on mismatch either migrate or reset to cold-start. Cheapest immediate win: fix the review_state.dart:12 doc comment, which currently states the wrong unit.
  4. 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.
  5. 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 the S=0.35, 5 d overdue case so a future weight retune cannot silently make it worse.
  6. Collapse the difficulty defaults to one named constant (C7). Export kDefaultDifficulty = 4.93 (or 0.0 + universal fallback) and use it in the schema, both row mappers, and :229.
  7. 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 that savePositionKnowledgeState / savePositionKnowledgeStates are 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.
  8. 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:


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:

  1. Reset to cold start. Honest and simple, but destroys every accumulated interval the moment a user taps a different algorithm name.
  2. Convert. A principled mapping exists (I(S, 0.88) = S × 1.242, so S = 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.
  3. 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.