Skip to content

feat(tutor): re-derivable FSRS schedule + per-learner difficulty fit#4

Merged
HarperZ9 merged 1 commit into
mainfrom
feat/fsrs-derive-optimize
Jul 3, 2026
Merged

feat(tutor): re-derivable FSRS schedule + per-learner difficulty fit#4
HarperZ9 merged 1 commit into
mainfrom
feat/fsrs-derive-optimize

Conversation

@HarperZ9

@HarperZ9 HarperZ9 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

What this ships

Makes the opt-in FSRS schedule a re-derivable function of the recorded scored attempts, and adds a lightweight per-learner initial-difficulty fit. New module src/tutor/fsrsderive.mjs.

The v1.6.0 FSRS work stores per-item state (session.itemState) as a mutable cached hint. This PR closes the accountability gap: it proves that hint is exactly what replaying the witnessed graded log produces, or reports the DRIFT.

Shipped (working + tested)

  • deriveItemStates(attempts) replays the FSRS-graded attempts (each carries grade + timestamp, written by tutor.recordAttemptWithGrade) through the same pure gradeAttempt() math the live scheduler uses. It reconstructs session.itemState bit-for-bit from session.attempts alone (proven by a parity test against the live state). Deterministic, no Date.now().
  • deriveScheduleReceipt(session) audits the cached hint against a clean un-primed replay and emits a MATCH / DRIFT / NO_FSRS_LOG verdict plus a hash-chained ledger over the graded attempts. The log-derived state is authoritative, so a stale or tampered cache is flagged as DRIFT with a per-field diff, never silently trusted.
  • optimizeParameters(attempts) fits one initial-difficulty prior per objective from that learner's own accuracy (all-correct history => easier start, all-wrong => harder). Honestly scoped: a documented heuristic prior, not a full FSRS weight optimization. Advisory only: it never moves the audit verdict or the mastery gate.
  • CLI: learn tutor derive-schedule <id> [--optimize], writes tutor/<id>.derive-schedule.json, non-zero exit on DRIFT.
  • MCP: learn_tutor_derive_schedule (optimize flag).
  • Doctor: new runtime invariant tutor.schedule_rederivable_from_log proves the audit can FAIL (clean session -> MATCH, tampered cache -> DRIFT). A schedule audit that cannot detect a tampered cache is not an audit.
  • Docs: README "Re-derivable schedule + per-learner fit" subsection; CHANGELOG Unreleased entry; status envelope capability + integrity invariant.

Specced / not in this PR (honest scope)

  • The per-learner fit is a single-parameter accuracy-based prior, not a gradient-descent fit of the full FSRS weight vector against a retention loss. That is the natural next slice and is labeled as such in code, README, and CHANGELOG. This PR does not claim FSRS-optimizer parity.

Integrity posture

fsrsderive.mjs is pure and I/O-free: no Date.now(), never grades, never appends to session.attempts, never feeds the mastery gate. itemState stays a hint; the witnessed hash-chained log stays the truth. The feature RIDES ON the existing receipt/ledger machinery (reuses accountability/ledger.mjs) rather than replacing it.

Test evidence

New: tests/learn-fsrs-derive.test.mjs (10) + tests/learn-fsrs-derive-cli.test.mjs (4) = 14 new tests. Full suite:

ℹ tests 298
ℹ pass 298
ℹ fail 0
learn doctor: MATCH
  ...
  [MATCH] tutor.schedule_rederivable_from_log

Key assertions: replay reproduces live itemState exactly; derivation is deterministic across repeats; intervals lengthen with successful recalls and shorten on lapse/fail; a monotone success streak yields monotonically longer intervals; a tampered cache is caught as DRIFT (module, CLI non-zero exit, and MCP); legacy correct/incorrect-only attempts are ignored by the replay.

Limitations

  • Only FSRS-graded attempts (with grade + timestamp) participate; plain Leitner correct/incorrect attempts are intentionally excluded from replay (they never wrote itemState).
  • The per-learner optimizer is a heuristic difficulty prior, not a full weight fit (see Specced above).
  • Numeric equality in the audit is exact; this is correct because the derivation math is deterministic, but it means any future change to the grade math would surface as DRIFT on old caches until re-derived (which is the intended behavior).

🤖 Generated with Claude Code

Add fsrsderive.mjs: reconstruct the FSRS scheduling state purely from the
witnessed graded attempt log and audit the cached itemState hint against it.

- deriveItemStates(attempts): replays the recorded scored attempts (grade +
  timestamp) through the same pure gradeAttempt math, reproducing itemState
  bit-for-bit from session.attempts alone.
- deriveScheduleReceipt(session): MATCH / DRIFT / NO_FSRS_LOG verdict plus a
  hash-chained ledger; the log-derived state is authoritative, so a stale or
  tampered cache is caught as DRIFT with a per-field diff, never trusted.
- optimizeParameters(attempts): advisory per-learner initial-difficulty prior
  fitted from the learner's own accuracy (heuristic, not full FSRS weight fit);
  never moves the audit verdict or the mastery gate.

Wired into CLI (tutor derive-schedule [--optimize], non-zero exit on DRIFT) and
MCP (learn_tutor_derive_schedule). New doctor invariant
tutor.schedule_rederivable_from_log proves the audit can FAIL.

INTEGRITY: pure, no Date.now(), never grades, never appends attempts, never
feeds the mastery gate. 14 new tests; full suite 298 pass; doctor MATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HarperZ9 HarperZ9 merged commit 8129654 into main Jul 3, 2026
6 checks passed
@HarperZ9 HarperZ9 deleted the feat/fsrs-derive-optimize branch July 3, 2026 02:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant