chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit

The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 19:49:52 +02:00
parent 75dd699f3e
commit e72b77d6c2
34 changed files with 2201 additions and 89 deletions

115
lib/sync.js Normal file
View File

@@ -0,0 +1,115 @@
/* ══════════════════════════════════════════════════════════════════════
SYNCING BETWEEN DEVICES — three gates, each paid for in lost user data
══════════════════════════════════════════════════════════════════════
The artifact shipped without these twice and destroyed real work twice.
Last-write-wins is adequate for one user whose worst conflict is a
duplicated SRS grade — but ONLY with all three of the following.
1. HYDRATION. A client may not push a document until the server has told
it what it already holds. This is the one that caused the loss: a
laptop with a week-old local copy ran a boot-time migration, which
re-stamped that stale copy with the current time and pushed it. A
boot-time write always looks like the newest edit in the world. A
week of phone work — chat and roadmap both — was gone. Note that rule
2 would NOT have saved it: the data was real, just old.
2. A COUNTER, NOT A CLOCK. Phone and laptop clocks disagree by minutes.
Compare a monotonic per-document counter; fall back to the timestamp
only when one side has none (an older client).
3. NO SILENT SHRINKING. A copy holding strictly less than the local one —
fewer chat turns, fewer finished units, fewer cards — is never adopted
by accident: keep the local copy and push it back. Deliberate
deletions set a flag and are obeyed. This is what made the loss
recoverable: the phone still had the real chat, refused the truncated
server copy, and restored it.
And: MIGRATIONS RUN AFTER THE FIRST PULL, NEVER AT BOOT, and never
rewrite history. The trigger for the whole incident was a one-shot repair
that cleared the chat. It was correct for the device it was written for
and a loaded gun for every other one. */
export const DOCS = ["srs", "log", "meta", "chat"];
/** How much a copy holds. Shrinking is always deliberate, never a race. */
export function weigh(name, d) {
if (!d) return 0;
if (name === "chat") return (d.turns || []).length;
if (name === "meta") return d.road && d.road.done ? Object.keys(d.road.done).length : 0;
if (name === "srs") return d.cards ? Object.keys(d.cards).length : 0;
if (name === "log") return d.days ? Object.keys(d.days).length : 0;
return 0;
}
/** Is the copy that just arrived later than ours? */
export function isLater(remote, localVersion, localStamp) {
if (typeof remote.v === "number" && localVersion > 0) {
if (remote.v !== localVersion) return remote.v > localVersion;
return typeof remote.u === "number" && remote.u > localStamp;
}
return typeof remote.u === "number" && remote.u > localStamp;
}
/**
* Decide what to do with an incoming document.
* @returns "ignore" | "adopt" | "reassert"
* reassert = ours holds more and nothing said the shrink was
* deliberate, so keep ours, bump past theirs, and push it back.
*/
export function reconcile(name, remote, local) {
if (!remote || !remote.d) return "ignore";
if (!isLater(remote, local.version || 0, local.stamp || 0)) return "ignore";
if (weigh(name, local.data) > weigh(name, remote.d) && !remote.x) return "reassert";
return "adopt";
}
/**
* The write side. Hold every edit until the document has been hydrated;
* an edit made before then keeps its old stamp so a newer server copy
* still wins.
*/
export function makeWriter({ push, now = () => Date.now() }) {
const state = {}; // name -> {version,stamp,dirty,hydrated,intent}
const S = n => (state[n] = state[n] || { version: 0, stamp: 0, dirty: false, hydrated: false, intent: false });
return {
state,
/** a normal user edit */
touch(name, deliberateShrink = false) {
const s = S(name);
s.dirty = true;
if (deliberateShrink) s.intent = true;
if (s.hydrated) { s.stamp = now(); s.version += 1; } // otherwise: hold
return s;
},
/** call when the first snapshot for this document arrives (or is known absent) */
hydrate(name) {
const s = S(name);
if (s.hydrated) return s;
s.hydrated = true;
if (s.dirty) { s.stamp = now(); s.version += 1; } // the held edit is real after all
return s;
},
adopted(name, remote) {
const s = S(name);
s.stamp = typeof remote.u === "number" ? remote.u : now();
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0);
s.dirty = false; s.intent = false;
return s;
},
reasserted(name, remote) {
const s = S(name);
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0) + 1;
s.stamp = now(); s.dirty = true;
return s;
},
flush(name, data) {
const s = S(name);
if (!s.dirty || !s.hydrated) return null;
s.dirty = false;
const body = { u: s.stamp, v: s.version, d: data };
if (s.intent) { body.x = 1; s.intent = false; }
return push(name, body);
},
};
}