Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
All notable changes to `learn`. Versions follow semantic versioning; each minor release was built
behind the `feat/learning-loop` branch and reviewed before merge.

## Unreleased

- `tutor/fsrsderive.mjs`: make the FSRS schedule a **re-derivable function of the witnessed graded
attempt log**. `deriveItemStates(attempts)` replays the recorded scored attempts (each carries its
`grade` + `timestamp`) through the same pure `gradeAttempt()` math the live scheduler uses, so it
reconstructs `session.itemState` bit-for-bit from `session.attempts` alone (proven by a parity test
against the live state). `deriveScheduleReceipt(session)` audits the cached hint against that clean
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 rather than trusted. `optimizeParameters(attempts)` adds an *advisory*
per-learner fit: one initial-difficulty prior per objective, fitted from that learner's own accuracy
(a documented heuristic prior, not a full FSRS weight optimization); it never moves the audit
verdict or the mastery gate. Wired into the CLI (`tutor derive-schedule <id> [--optimize]`, writes
`tutor/<id>.derive-schedule.json`, non-zero exit on DRIFT) and MCP (`learn_tutor_derive_schedule`,
`optimize`). A new `doctor` invariant `tutor.schedule_rederivable_from_log` proves the audit can
FAIL: a clean session re-derives to MATCH and a tampered cache is caught as DRIFT. INTEGRITY
unchanged: `fsrsderive.mjs` is pure, never calls `Date.now()`, never grades, never appends to
`session.attempts`, and never feeds the mastery gate: `itemState` stays a hint, the witnessed log
stays the truth. Backward compatible: all prior tests pass; 14 new tests across
`learn-fsrs-derive.test.mjs` (10) and `learn-fsrs-derive-cli.test.mjs` (4).

## 1.6.0

- `tutor/fsrs.mjs` + `tutor/itemscheduler.mjs`: opt-in FSRS-class adaptive memory model.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ capability matches where you are stuck:
the "ready" line. The flags are advisory — on a session created without `--enable-fsrs` they fall
back to the Leitner/interleave path. Timestamps are always injected (`--now`), so schedules are
fully deterministic and re-checkable.
- **Re-derivable schedule + per-learner fit** (`fsrsderive.mjs`): the FSRS schedule is not just a
stored blob, it is a *re-derivable function of your recorded scored attempts*. `derive-schedule`
replays the witnessed graded log (each attempt carries its `grade` and `timestamp`) to rebuild the
scheduling state from scratch, then audits it against the cached `itemState`:

```sh
learn tutor derive-schedule sess # verdict MATCH / DRIFT / NO_FSRS_LOG
learn tutor derive-schedule sess --optimize # also fit a per-learner initial-difficulty prior
```

The log-derived state is authoritative, so a stale or tampered cache is caught as **DRIFT** with a
per-field diff (and a non-zero exit), never silently trusted. The receipt carries a hash-chained
ledger over the graded attempts, so it is tamper-evident like every other receipt in `learn`.
`--optimize` is *advisory*: it fits one initial-difficulty prior per objective from your own
accuracy on that objective (all-correct history => easier start, all-wrong => harder). It is a
documented heuristic prior, not a full FSRS weight optimization, and it never changes the audit
verdict or the mastery gate. Like everything above, `itemState` is a hint; the mastery-gate still
reads your witnessed `attempts` only.
- **Retrieval practice from your own material** (`retrieval.mjs`): turns claims your *own* draft
already asserts (via `assist`) into blanked cloze prompts you answer from memory, each carrying
its source so you can check yourself after, not before.
Expand Down
15 changes: 14 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,20 @@ export async function main(argv, { dir = process.cwd() } = {}) {
writeFileSync(join(dir, "tutor", id + ".study-receipt.json"), JSON.stringify(r, null, 2));
return { code: 0, out: `tutor study-receipt ${id}: verified ${r.verified}, mastery ${r.mastery.ready ? "READY" : "not yet"} -> tutor/${id}.study-receipt.json` };
}
return { code: 1, out: "usage: learn tutor <plan|record|mastery|receipt|reverify|prooflesson|due|misconceptions|retrieval|explain|predict|score|path|study|study-receipt> <id> ..." };
if (sub === "derive-schedule") {
const s = loadSession(dir, id); if (!s) return { code: 1, out: `no tutor session: ${id}` };
const { deriveScheduleReceipt } = await import("./tutor/fsrsderive.mjs");
const r = deriveScheduleReceipt(s, { optimize: has("--optimize") });
mkdirSync(join(dir, "tutor"), { recursive: true });
writeFileSync(join(dir, "tutor", id + ".derive-schedule.json"), JSON.stringify(r, null, 2));
const drift = r.perObjective.filter((p) => !p.match).map((p) => p.objective);
const driftNote = drift.length ? `\n DRIFT on: ${drift.join(", ")} (cached hint disagrees with the witnessed log; the log is authoritative)` : "";
return {
code: r.verdict === "DRIFT" ? 1 : 0,
out: `tutor derive-schedule ${id}: verdict ${r.verdict}, re-derived ${r.fsrsAttempts} graded attempt(s), ledger ${r.ledgerVerified ? "verified" : "BROKEN"} -> tutor/${id}.derive-schedule.json${driftNote}`,
};
}
return { code: 1, out: "usage: learn tutor <plan|record|mastery|receipt|reverify|prooflesson|due|misconceptions|retrieval|explain|predict|score|path|study|study-receipt|derive-schedule> <id> ..." };
}
if (cmd === "assist") {
const { assistArtifacts } = await import("./assist/assist.mjs");
Expand Down
14 changes: 14 additions & 0 deletions src/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { newSession, recordAttempt, mastery, recordVisualization } from "./tutor
import { recordPrediction } from "./tutor/predict.mjs";
import { reverifySelfCheck } from "./tutor/reverify.mjs";
import { proofLessonSelfCheck } from "./tutor/prooflessonverify.mjs";
import { newSessionWithFSRS, recordAttemptWithGrade } from "./tutor/tutor.mjs";
import { deriveScheduleReceipt } from "./tutor/fsrsderive.mjs";

export async function doctor() {
const checks = [];
Expand Down Expand Up @@ -82,6 +84,18 @@ export async function doctor() {
// chainless receipt). A verifier that cannot fail on a known-bad input is not a verifier.
add("tutor.prooflesson_rejects_known_bad", proofLessonSelfCheck());

// 10. the FSRS schedule is a RE-DERIVABLE function of the witnessed graded log, and the audit can
// FAIL: a clean session re-derives to MATCH, and tampering the cached itemState (which never feeds
// the mastery gate) is caught as DRIFT rather than trusted. A schedule audit that cannot detect a
// tampered cache is not an audit.
const fs = newSessionWithFSRS({ topic: "doctor-fsrs", objectives: ["x"] });
recordAttemptWithGrade(fs, { objective: "x", grade: 3, now: "2026-06-30T00:00:00.000Z" });
recordAttemptWithGrade(fs, { objective: "x", grade: 4, now: "2026-07-02T00:00:00.000Z" });
const cleanVerdict = deriveScheduleReceipt(fs).verdict;
fs.itemState.x.stability = 99999; // tamper the cached hint
const tamperedVerdict = deriveScheduleReceipt(fs).verdict;
add("tutor.schedule_rederivable_from_log", cleanVerdict === "MATCH" && tamperedVerdict === "DRIFT");

const ok = checks.every((c) => c.status === "MATCH");
return { tool: "learn", version, status: ok ? "MATCH" : "DEGRADED", checks };
}
6 changes: 6 additions & 0 deletions src/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const TOOLS = [
{ name: "learn_tutor_studyplan", description: "Advisory, read-only: return the composed study plan for a saved tutor session (due list, ranked misconceptions, study order, prerequisite readiness, mastery-gate verdict). Set useFsrs (with an FSRS-enabled session) to rank study order by retrievability (most-at-risk first) against desiredRetention.", inputSchema: { type: "object", properties: { sessionId: { type: "string" }, now: { type: ["string", "number"] }, seed: { type: ["string", "number"] }, useFsrs: { type: "boolean" }, desiredRetention: { type: "number" } }, required: ["sessionId", "now"] } },
{ name: "learn_tutor_misconceptions", description: "Advisory, read-only: return the ranked misconception aggregation (wrong attempts + the operator's own feedback) for a saved tutor session.", inputSchema: { type: "object", properties: { sessionId: { type: "string" } }, required: ["sessionId"] } },
{ name: "learn_tutor_reverify", description: "Advisory, read-only: re-verify a session's emitted tutor receipts from their own recorded evidence (recomputed hash chain + re-derived mastery verdict). Failures are typed CHAIN_BROKEN / VERDICT_MISMATCH; a chainless receipt is UNVERIFIED, never verified.", inputSchema: { type: "object", properties: { sessionId: { type: "string" }, file: { type: "string" } }, required: ["sessionId"] } },
{ name: "learn_tutor_derive_schedule", description: "Advisory, read-only: re-derive the FSRS scheduling state PURELY from the session's witnessed graded attempt log and compare it to the cached itemState hint. Returns 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. Set optimize to seed the derivation with a per-learner initial-difficulty prior fitted from the learner's own accuracy. Never grades, never appends attempts, never moves the mastery gate.", inputSchema: { type: "object", properties: { sessionId: { type: "string" }, optimize: { type: "boolean" } }, required: ["sessionId"] } },
{ name: "learn_tutor_prooflesson", description: "Advisory, read-only: derive a lesson from a proof packet (source refs, claim, verdict, explanation scaffold, retrieval questions, verifier binding) plus a typed misconception record for DRIFT/UNVERIFIABLE packets. The lesson verdict always equals the packet verdict; a forged verdict enum is rejected; nothing is written.", inputSchema: { type: "object", properties: { packet: { type: "object" }, packetPath: { type: "string" } } } },
];

Expand Down Expand Up @@ -96,6 +97,11 @@ export async function dispatch(name, args = {}, { dir = process.cwd() } = {}) {
if (!packet) throw new Error("learn_tutor_prooflesson requires `packet` (object) or `packetPath` (file)");
return { lesson: proofLesson(packet), misconception: misconceptionFromPacket(packet) };
}
case "learn_tutor_derive_schedule": {
const s = loadSession(dir, args.sessionId); if (!s) throw new Error("no tutor session: " + args.sessionId);
const { deriveScheduleReceipt } = await import("./tutor/fsrsderive.mjs");
return { sessionId: args.sessionId, ...deriveScheduleReceipt(s, { optimize: !!args.optimize }) };
}
default: throw new Error(`unknown tool: ${name}`);
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function status() {
note: "submission mode affects `submit` only; `assess` (graded work) always halts regardless.",
tutor: "teach-you loop: objectives -> practice (operator solves) -> self-check -> mastery-gate; witnessed practice log; never supplies real graded-assessment answers",
learningLoop: {
schedule: "spaced repetition (SM-2-lite/Leitner ladder) over the operator's own practice log; due() reports objectives due for review, most-overdue first",
schedule: "spaced repetition (SM-2-lite/Leitner ladder) over the operator's own practice log; due() reports objectives due for review, most-overdue first. Opt-in FSRS-class path (fsrs.mjs + itemscheduler.mjs) tracks per-item difficulty/stability/retrievability and schedules against a retention target",
deriveSchedule: "derive-schedule re-derives the FSRS scheduling state PURELY from the witnessed graded attempt log (each attempt carries grade + timestamp) and audits it against the cached itemState hint: MATCH / DRIFT / NO_FSRS_LOG plus a hash-chained ledger. The log-derived state is authoritative, so a stale or tampered cache is caught as DRIFT, never trusted. Optional per-learner fit seeds an initial-difficulty prior from the learner's own accuracy (advisory; heuristic, not a full FSRS weight optimization)",
misconception: "aggregates the operator's own WRONG attempts + their feedback per objective, ranked by count, to prioritize the next study session",
retrieval: "clozePrompts turns the operator's OWN assist-extracted claims into blanked recall prompts carrying a source; interleave() gives a deterministic (seeded, no Math.random) mixed study order",
explain: "self-explanation: wraps the operator's OWN explanation into a crucible thesis and buckets MATCH/DRIFT/UNVERIFIABLE verdicts into grounded/shaky/unverifiable",
Expand All @@ -38,6 +39,7 @@ export function status() {
"credentials, payment, CAPTCHA, and account creation halt for the operator",
"aid visualizations are learning aids only — they are witnessed but never satisfy an assess step or enter the graded receipt channels",
"tutor receipts re-verify from their own recorded evidence, never from author-controlled booleans (CHAIN_BROKEN / VERDICT_MISMATCH; chainless receipts are never verified)",
"the FSRS schedule is a re-derivable function of the witnessed graded log: derive-schedule reconstructs it from the recorded scored attempts and flags a tampered cache as DRIFT; the scheduling hint never feeds the mastery gate",
],
boundary: "The engine never produces graded work. It is a learning aid, not a bypass.",
};
Expand Down
Loading
Loading